0
0
Fork 0
Commit graph

46 commits

Author SHA1 Message Date
Karstein Phobic Nyvold Kvistad
19d6cc3d8b fix(create_folder): dump dir(parent) on total-miss to diagnose unknown SPs
Empirical failure: TypeError create_folder() got an unexpected keyword argument 'name' (original v1).
Root cause: kwarg 'name=' rejected by SP21+; positional foldername is the canonical signature.
Fix: positional call (already landed in e0fea90); this commit adds a dir(parent) dump on total-miss for forward-compat diagnostics.
Docs: https://content.helpme-codesys.com/en/ScriptingEngine/ScriptObject.html

Note: the core fix (positional call + multi-strategy fallback) was already
landed in commit e0fea90. This commit only adds the dir(parent_object) dump
to the final error path -- per the bug doc's "dump dir(parent) on total
miss" recommendation -- so an SP that breaks all 5 strategies surfaces the
real API surface in the failure message instead of leaving the next
investigator blind.

### Manual smoke test
1. mcp__codesys__create_folder against any normally-functioning project
   should still succeed (Strategy 1 wins -- the dir() dump only triggers
   when ALL strategies fail).
2. To exercise the new dir() path, run create_folder against a project
   whose Application has been deleted (parent_object resolves to a
   container without create_folder/create_object/add): expect SCRIPT_ERROR
   ending with "parent api: <space-separated attr names>".
2026-04-28 20:51:32 +02:00
Karstein Phobic Nyvold Kvistad
763a30761d fix(compile): deep-walk message structures with _coerce_for_json before json.dumps
Empirical failure: TypeError 281474976710655L is not JSON serializable from get_message_objects().
Root cause: previous per-attribute coercion only flattened known fields (severity/text/line); nested dicts/lists carrying CLR longs slipped through.
Fix: add a recursive _coerce_for_json helper that walks dicts/lists/tuples and downcasts long->int (or str if >Int64), keeps bool, then call it before every json.dumps in both compile_project.py and get_compile_messages.py.
Docs: https://content.helpme-codesys.com/en/ScriptingEngine/ScriptSystem.html (get_message_objects), https://content.helpme-codesys.com/en/ScriptingEngine/ScriptObject.html

Note: previous commit 418f678 added the per-field coercers; this commit
strengthens that with a recursive walker per the bug doc's proposal --
defensive against any future API change that nests longs deeper.

Manual smoke test only -- requires CODESYS-in-the-loop with a project
that produces compile messages whose severity bitmask is 0xFFFFFFFFFFFF.

### Manual smoke test
1. Open a project with at least one warning or error.
2. Call mcp__codesys__compile_project on it.
3. Expect SCRIPT_SUCCESS and a populated COMPILE_MESSAGES_START block;
   no TypeError 'is not JSON serializable' anywhere in the output.
4. Repeat with mcp__codesys__get_compile_messages.
2026-04-28 20:50:15 +02:00
Karstein Phobic Nyvold Kvistad
be42f60488 fix(mirror): per-project mirror dir name when multiple .project files share a parent
The default mirror root was hard-coded to <projectDir>/mcp-mirror/,
which collides when two .project files live in the same folder
(e.g. \files\...\Multi plc test\ProjectA.project +
ProjectB.project both default to the same mirror -- each
mirror_export call clobbers the other's output).

New resolution rule (preserves backward-compat -- existing setups
unaffected):
- If <projectDir>/mcp-mirror/ already exists, use it
- Else if exactly one .project sibling, use <projectDir>/mcp-mirror/
- Else (multiple .project) use <projectDir>/<basename>_mcp_mirror/

Implemented as src/mirror-paths.ts (TS helper, used by server.ts
maybeOpenMirrorInVscode and unit-testable) plus the same logic
inlined in the Python scripts that need it (mirror_export.py et al.,
no shared-import infra exists CODESYS-side).
2026-04-27 22:22:09 +02:00
Karstein Phobic Nyvold Kvistad
5be20a6e3b remove(codesys-git): drop all 6 CODESYS Git plugin tools
The CODESYS Git plugin wrappers (git_init, git_status, git_commit,
git_remote_add, git_branch_set_upstream_to, git_push) operated on the
binary .project file via CODESYS's IDE-side Git plugin. Drawbacks:

- Required a CODESYS Professional Developer Edition subscription
  (HasGitLicense gate). Anyone without PDE got a fail-fast error on
  every call -- the tools were dead weight for most users.
- Operated on a separate dual-storage repo (the .project stayed put,
  the git repo lived in a sibling directory). Diffs were unreadable
  because they're binary serialisations, not source text.
- Couldn't run on UNC paths -- the plugin rejected them.
- Duplicated functionality release_project_version already provides
  via the system git binary against the source-mirror tree (which IS
  human-readable diff-able .st files).

Removing all 6 tools, all 6 .py templates, and the README section.
Tool count drops 37 -> 31. release_project_version remains the
recommended path for CODESYS-project version control: mirror_export
gives you readable diffs in mcp-mirror/, then standard git commits
+ tags + push, no PDE license required.
2026-04-27 21:43:52 +02:00
Karstein Phobic Nyvold Kvistad
04b46fb49a fix(read_running_version_online): document the real root cause + fix
Three changes after deeper investigation against PLATEA Win V3:

1. The 'symbol' := 'read' attribute experiment didn't help. Reverted
   bump_project_version's GVL template to plain VAR_GLOBAL +
   qualified_only (matches what shipped originally, minus CONSTANT).
   Comment updated to explain both why CONSTANT is wrong (compile-time
   inlining) and why the symbol attribute alone wasn't enough (it
   requires a Symbol Configuration object to do anything).

2. Real root cause for read_running_version_online's 'Invalid expression':
   CODESYS strips unreferenced GVLs from the online symbol table at
   compile time, regardless of attribute pragmas. The version anchor by
   definition has no IEC code reading it, so the optimizer drops it.
   GVL_Test.bRun reads fine despite no references because GVL_Test has
   OTHER referenced variables; entire-GVL retention seems to be the
   stripping unit, not per-variable.
   Verified end-to-end: adding 'sVersionTag := _MCP_PROJECT_VERSION
   .sVersion;' in PLC_PRG made the read return '1.4.1.0' on PLATEA.

3. Updated the read_running_version_online error message to surface
   BOTH the (now-rare) CONSTANT case AND the (common) unreferenced-GVL
   case, with the exact 2-line code snippet a user needs to paste into
   their main program. The bump tool intentionally does NOT auto-inject
   this -- modifying user code on every release was deemed too invasive.
   Documented the requirement in TEST_OVERVIEW.md alongside the v5
   sweep notes.

37/37 tests still green. v1.4.2.0 of MCPTest2 carries the working
PLC_PRG reference as the canonical demonstration.
2026-04-26 20:13:25 +02:00
Karstein Phobic Nyvold Kvistad
ef259c8ee3 fix(online tools): auto-login + non-CONSTANT version GVL
Two related v5-sweep fixes for the online/runtime tool family:

1. Auto-login helper for headless mode

   In headless mode each MCP call spawns a fresh CODESYS --noUI process,
   so the login state established by connect_to_device dies before the
   next call. Pre-fix, only connect_to_device and download_to_device did
   their own login(); the other four (start_stop_application,
   read_variable, write_variable, read_running_version_online) silently
   failed in headless with 'Application not logged in.' (start/stop) or
   'Invalid expression' (read/write). They worked in persistent mode
   only because the login carried across calls.

   Added ensure_logged_in(online_app, login_wait_seconds=30) to
   ensure_online_connection.py. Idempotent: short-circuits via
   online_app.is_logged_in (persistent mode is a no-op, no extra login
   roundtrip). When not logged in, runs the same enum-probe + call-shape
   probe + STABLE_STATES settle-wait pattern as connect_to_device.py.
   Added to start_stop_application.py, read_variable.py,
   write_variable.py, read_running_version_online.py.

2. _MCP_PROJECT_VERSION GVL emitted as plain VAR_GLOBAL, not CONSTANT

   CODESYS inlines VAR_GLOBAL CONSTANT scalars at compile time and
   strips them from the online symbol table. The whole point of
   _MCP_PROJECT_VERSION.sVersion is to be readable live from the
   running PLC, so CONSTANT was the wrong storage class.
   read_running_version_online failed against EVERY project bumped via
   the old template -- 'Invalid expression' on the runtime read.

   Dropped CONSTANT from VERSION_GVL_DECLARATION_TEMPLATE in
   bump_project_version.py. Existing projects auto-migrate on the next
   bump because maintain_version_gvl()'s existing-GVL branch overwrites
   textual_declaration with the (now non-CONSTANT) template. The string
   is still effectively read-only at runtime -- only the bump tool
   updates it.

   read_running_version_online.py also got a more precise error message
   that explicitly fingerprints the 'Invalid expression' failure mode
   and points at the CONSTANT root cause. Useful for any user landing
   on a project that pre-dates this fix.

Verified end-to-end against local CODESYS Control Win V3 (PLATEA, port
11740) on MCPTest2 v1.3.4.0:
- connect_to_device, get_application_state, download_to_device,
  start_stop_application (both directions), read_variable
  (PLC_PRG.watchdog1 = 225 ticking), write_variable (200 -> 204 in 4s
  proves write took), disconnect_from_device: all 7 PASS.
- read_running_version_online failure reproduced (CONSTANT inlined),
  fix landed -- next bump on MCPTest2 will validate.

37/37 unit/integration tests green. TEST_OVERVIEW.md updated with the
v5 device sweep, with the headless-mode deep-dive, and with the
broken-by-design notes on read_running_version_online.
2026-04-26 19:54:13 +02:00
Karstein Phobic Nyvold Kvistad
d414c779a5 fix(add_library): refuse to save unresolvable placeholders
Prior behaviour: lm.add_library(LIBRARY_NAME) was called with a string,
which always hits the placeholder overload of ScriptLibManObject.add_library
(see helpme-codesys.com "ScriptLibManObject" / local SP22 stub
ScriptLib/Stubs/scriptengine/ScriptLibManObject.pyi). If the named
placeholder is not registered in the IDE, the resulting reference fails
to resolve at load time and the next project open throws

  Library Manager: Error: Could not open library 'X'.
  (Reason: The placeholder library 'X' could not be resolved.)

after which script_engine.projects.primary returns None and the project
is effectively bricked until the binary is reverted. add_library reported
SUCCESS in this scenario.

Fix:
  1. Pre-resolve LIBRARY_NAME via the IDE-level
     library_manager.find_library(name) global. If found, pass the
     resulting ManagedLib to lm.add_library(...) -- the V3.5.5.0 ManagedLib
     overload which produces a managed reference instead of a placeholder.
  2. After the add, walk lm.references to locate the new entry and verify
     it resolved (managed -> always; placeholder -> non-empty
     effective_resolution per ScriptPlaceholderReference in the stub).
  3. If the reference did not resolve, call lm.remove_library(name) to back
     out the bad reference and refuse to save the project, returning an
     actionable error instead.

Also tightened the libman lookup to use the documented
has_library_manager / get_library_manager() container API
(ScriptLibManObjectContainer in the stub) instead of name-searching for
"Library Manager" as a tree node, matching what list_project_libraries.py
already does. The legacy name-search fallback is preserved for older SPs.

Regression test added in tests/integration/e2e.test.ts asserting the
rendered script template carries the resolution gate, the managed-overload
preference, the back-out call, and that primary_project.save() in the
add_library body lives downstream of the _is_resolved gate.

Followup: lm.remove_library(name) is documented for SP22 and is the
clean back-out path. On SPs that lack remove_library (none observed in
the 3.5.21+ docs but possible on truly old branches) the script reports
the constraint and exits non-zero rather than silently saving a bad
reference.
2026-04-26 19:44:00 +02:00
Karstein Phobic Nyvold Kvistad
888a035c0c feat(list_project_libraries): capture project compiler version
Calls primary_project.get_compilerversion() (ScriptEngine 4.2.0.0+) and
emits the result through the JSON payload. Renders as:

  - library.md: a 'Project compiler version' row in the Versions table
  - list_project_libraries chat output: a 'Compiler version: X.Y.Z.W'
    line in the header section

Motivation: changing the project's compiler version (Project > Project
Settings > Compiler version, or set_compilerversion_to_newest()) only
touched the .project binary -- mirror_export couldn't see it, so the
release classifier had to fall back to SHA comparison and emitted the
generic 'device-tree / library refs / task config / visu / Save() touch'
classification. Compiler-version changes now leave a textual diff in
mcp-mirror/library.md, letting the classifier issue an honest revision
bump instead of the bare build-bump SHA fallback.

Defensive: get_compilerversion() is wrapped in try/except so older
ScriptEngines (< 4.2.0.0) that lack the method don't crash the tool;
they just emit compiler_version=null and the field is omitted from
output.
2026-04-26 19:18:31 +02:00
Karstein Phobic Nyvold Kvistad
35abc8cb52 fix(set_pou_code): omitted declaration/impl no longer wipes the POU
Bug: calling set_pou_code with implementationCode only (declarationCode
omitted) would wipe the POU's PROGRAM/VAR...END_VAR block in the binary.
After such a call mirror_export classified the POU as 'UNKNOWN' (no
PROGRAM/FUNCTION_BLOCK keyword in the empty declaration), and the var
block disappeared from the .st mirror file.

Root cause: the TS wrapper substituted '' (empty string) into the Python
template when declarationCode was undefined, giving DECLARATION_CONTENT
= "". The Python script then took the truthy-ish branch (empty string
is not None) and called decl_obj.replace('') -- wiping textual_decl.

Fix: pass explicit SET_DECLARATION / SET_IMPLEMENTATION boolean flags
from the TS wrapper, gate the replace() calls on those flags. Empty
string remains a valid intentional value (caller wants to wipe).

- Reproduced on MCPTest2: PLC_PRG declaration block was wiped between
  v1.3.0.0 and v1.3.1.0 by exactly this code path.
- Regression test added in tests/integration/e2e.test.ts covering the
  omitted-declarationCode path.
- Existing set_pou_code test extended to assert SET_DECLARATION /
  SET_IMPLEMENTATION are emitted in the rendered script.
2026-04-26 18:41:48 +02:00
Karstein Kvistad
c87f3a9179 fix(create_folder): WORKING -- create_folder returns void, walk children to detect success
The v3 fix from 32e6120 still fell through every strategy because the
None-as-failure check was wrong. CODESYS scripting's create_folder
methods (both ScriptObject.create_folder and
ScriptProject.create_folder) RETURN VOID -- the folder is created via
side effect; the return value is Python None. v1/v2/v3 all treated
None as "this strategy didn't work" and tried the next, eventually
giving up.

v4 fix: after each create call, walk parent_object.get_children(False)
looking for a child whose get_name() == FOLDER_NAME. First strategy
that produces such a child wins; the rest are skipped (avoids
duplicates). The strategy order also got reshuffled: parent.create_folder
positional is now strategy 1 (per the SP22 ScriptObject stub
signature), with project-level fallbacks behind it.

Verified end-to-end on MCPTest2 + SP22 P1:
  > create_folder(folderName='Test_Bench_Folder',
                  parentPath='PLCWinNT/Plc Logic/Application')
  Folder 'Test_Bench_Folder' created [...]. Project saved.
  > delete_object(objectPath='.../Test_Bench_Folder')
  Object [...] deleted [...]. Project saved.

Per-version trace of the iteration:
  v1 (2607063): name= kwarg -> "create_folder() got an unexpected
                                kwarg 'name'" -- SP22 stub uses
                                foldername.
  v2 (e07f281): positional / foldername= -> silent None return on
                                Application; fell through.
  v3 (32e6120): added primary_project.create_folder(name, SV_POU)
                first; also dropped ScriptManager cache for hot-reload.
                Still treated None as failure.
  v4 (this):    walk children after each call; succeeds.

Memory note for the kit: the SP22 scripting API has a class of
methods that mutate via side effect and return void. When porting
fork scripts, ALWAYS verify by walking children, never by checking
the return value of create_*. Same pattern probably applies to
create_pou / create_dut / create_gvl too -- worth a separate audit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:23:26 +02:00
Karstein Kvistad
32e612000d fix(create_folder): try project.create_folder(name, SV_POU) first; drop ScriptManager cache
create_folder v2 (positional foldername) returned None silently against
the SP22 Application object -- no exception raised, no folder created.
Investigation showed:
  - ScriptObject.create_folder(foldername) is documented to "create a
    folder in the structured view of the parent node", but on Application
    specifically it's a silent no-op (the structured view isn't pinned to
    POU view there).
  - ScriptProject.create_folder(foldername, structured_view=None) on the
    project itself with explicit SV_POU GUID
    ({21AF5390-2942-461a-BF89-951AAF6999F1}) is the documented and
    reliable pathway -- the resulting folder appears under Application
    in the IDE tree because that's where SV_POU lives.

v3 fix: try strategies in order until one returns non-None:
  (1) primary_project.create_folder(name, SV_POU_GUID) -- new, primary
  (2) parent.create_folder(name) positional -- pre-SP21 path
  (3) parent.create_folder(foldername=name) -- alt keyword
  (4) primary_project.create_folder(name) -- default view
  (5) parent.create_object(typeUuid='85d1215e-...') -- alt factory
  (6) parent.add(script_engine.types.IecFolder, name=name) -- legacy
Each strategy guards on hasattr + return-value-not-None, so a silent
no-op falls through instead of being mistaken for success.

ScriptManager: dropped the in-memory template cache. Each loadTemplate
call now reads the .py from disk fresh. Cost: ~1ms per call vs ~1.5s
of CODESYS execution time -- invisible. Win: edits to dist/scripts/
take effect without an MCP restart, which makes iterating on script-
side fixes (like this very create_folder loop) much faster. Existing
"cache hit" unit test rewritten as "two loads return equal content".

tests/test-fixes.mjs: standalone harness that drives a single persistent
CODESYS through HeadlessExecutor + CodesysLauncher to verify the four
broken-tool fixes end-to-end. Useful for regression-testing without
needing a vsc reboot loop. Currently only smoke-tests
create_folder + compile + cross-project; expand as more fixes need
verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:16:44 +02:00
Karstein Kvistad
e07f281fd0 fix(create_folder): use positional / foldername= instead of name= kwarg
The v1 fix from 2607063 used `parent.create_folder(name=FOLDER_NAME)`
which raised "create_folder() got an unexpected keyword argument 'name'"
on SP22 -- the actual stub signature in
  C:\Program Files\CODESYS 3.5.22.10\CODESYS\ScriptLib\Stubs\scriptengine\ScriptObject.pyi
is
  def create_folder(self, foldername): ...

The fallback chain (create_object + add(IecFolder)) caught the failure
and reported correctly, so no damage -- but the primary path was wrong.

Verified via the SP22 stubs that the keyword is `foldername`. Use
positional form first (agnostic to the keyword name across SP releases),
fall through to foldername= if positional fails for some reason, then
keep the existing alternate-factory chain as deeper fallbacks.

Tested partially in this session: create_folder DEBUG output confirmed
the v1 fix's fallback chain was running as designed; the keyword fix
will be runtime-verified on the next vsc reboot since script-manager
caches dist/scripts at MCP startup.

Verification trace (v1 against SP22):
  DEBUG: Calling parent.create_folder(name='Test_Bench_Folder')
  WARN: parent.create_folder() raised: create_folder() got an
        unexpected keyword argument 'name'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:06:11 +02:00
Karstein Kvistad
2607063306 fix: 4 broken-tool fixes (compile json long, connect_to_device LoginMode,
create_folder SP21+ fallback, ensure_project_open cross-project switch)
+ bench results

compile_project.py + get_compile_messages.py:
- IronPython 2.7's json.dumps cannot serialize System.Int64-backed `long`
  values, which is what CODESYS's compile-message objects expose as
  line_number / position. Added _coerce_int + _coerce_str helpers and a
  shared _build_message_entry function. Three duplicated message-building
  blocks collapsed into single helper calls.
- Defensive `try: json.dumps(...) except TypeError: json.dumps(default=str)`
  so a stray field that slips past the helpers doesn't kill the emit.

connect_to_device.py:
- SP21+ may expose the login enum as LoginMode rather than
  OnlineChangeOption. Extended the candidate sweep to probe both
  script_engine.LoginMode and script_engine.OnlineChangeOption AND
  online_app.LoginMode/OnlineChangeOption (some builds attach it to the
  app object). Added "OnlineChange" + "Login" + "Download" to the
  preferred-priority list. Added a 3-arg call shape variant for SPs
  that take (mode, secondary-mode, force-bool).

create_folder.py:
- parent_object.create_folder() is no longer exposed on every parent type
  in SP21+. Added two fallback factories tried in order:
    1. parent.create_object(typeUuid='85d1215e-6520-4983-9a55-2d39d1f24cb4', name=...)
    2. parent.add(script_engine.types.IecFolder, name=...)
  with detailed warnings when each path fails. Final TypeError now lists
  every factory tried so a future SP rotation surfaces clearly.

ensure_project_open.py:
- Uncommented the close-prior-project branch (was a TODO since the
  initial fork). Cross-project switches in a persistent CODESYS now do
  save() -> close() -> 500ms pump -> open(target). Without this,
  projects.open against a different already-primary project fails
  intermittently on file lock contention or pops a "project in use"
  modal that freezes the IDE thread.
- save() is best-effort: if it raises (transient lock, save-as required)
  we still proceed with close + open rather than getting stuck in a
  half-switched state forever.

tests/bench-results.json:
- Captured timings from a clean run on MCPTest2 (PLCWinNT, 5 lib refs,
  ~12 POUs). 9 tools x 2 modes x iterations.
  Headers (mean ms): persistent vs headless --
    open_project              7700  vs   40041   (~5x; first call cold)
    mirror_export             1547  vs   23723   (~15x)
    list_project_libraries    1565  vs   23322   (~15x)
    get_all_pou_code          1607  vs   23376   (~15x)
    save_project              2095  vs   23321   (~11x)
    create_pou (FB)           1540  vs   23903   (~16x)
    delete_object             1544  vs   27420   (~18x)
    bump_project_version      1540  vs   30678   (~20x)
    bump_project_version #2   1556  vs   37769   (~24x)
- set_pou_code FAILED in both modes -- bench harness param-shape issue
  (multi-line code passed verbatim to triple-quoted-string interpolation
  doesn't survive the round-trip). Tool itself works fine through the
  MCP tool call path; bench needs to escape newlines / use the same
  prepareScriptWithHelpers shape the server uses. Filed for follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:53:25 +02:00
Karstein Kvistad
b42e10411f fix(bump_project_version): cross-check pi.version against GVL, take the max
Recurring "GVL misread" bug in release_project_version: when
Project Information.Version drifts BEHIND the runtime-anchor GVL
(_MCP_PROJECT_VERSION.sVersion), the bump used the stale pi.version
as the resume point and silently regressed the version, often
colliding with an existing v* tag.

Observed twice on the MCPTest2 sandbox:

  1. v1.0.4.0 (2026-04-26): bump from on-disk 1.2.0.0 read pi.version
     as 1.0.3.0 -> revision -> 1.0.4.0. Tag deleted; recovered as
     v1.2.1.0 via manual finish script.

  2. v1.1.0.0 collision (2026-04-26): bump from on-disk 1.2.1.0 read
     pi.version as 1.0.0.0 -> minor -> 1.1.0.0. Tag already existed,
     git tag step failed, release pipeline aborted. Recovered by
     two manual minor bumps (1.1.0.0 -> 1.2.0.0 -> 1.3.0.0) and an
     amended commit, released as v1.3.0.0.

Root cause: the MCPTest2 v1.2.0.0 and v1.2.1.0 releases were
finished by external (non-MCP) Node scripts that updated the GVL
via inject-once but never wrote pi.version back through the
bump_project_version pathway. So pi.version stayed pinned at
whatever value the LAST true bump_project_version run left it at
(in MCPTest2's case, ~1.0.0.0), while the GVL kept moving forward.

Fix: in the pi-present branch, read both pi.version and the GVL
sVersion, parse both as 4-tuples, and take the max as the resume
point. The max is always safe: both sides only ever move forward
in the normal case, so the higher of the two is by construction
the true latest version. When a drift is detected (pi behind GVL),
emit a WARNING and self-heal pi.version forward to the GVL value
before the bump so the warning doesn't recur on the next call.

The pi-missing branch is unchanged (still falls back to GVL).

Documented inline in the function with the regression scenario for
future maintainers. No new test (the affected logic runs inside
CODESYS's IronPython and doesn't have a unit-test scaffold here);
the inline comment + this commit message are the regression record.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:19:27 +02:00
Karstein Phobic Nyvold Kvistad
1a05312cf1 mirror_export: drop the 'Generated: <timestamp>' header line
REAL root cause of the phantom-release loop. The classifier fixes
in a570132 (--ignore-cr-at-eol -w) and the .gitattributes pinning
on the project repos addressed CRLF noise -- but the actual
trigger turned out to be deeper.

Every mirror_export run was emitting a line like:

  (* Generated:    2026-04-26 01:25:14 *)

at the top of every .st file. Wall-clock time. So every run produced
byte-different output even when the project hadn't changed at all.
git diff (with or without whitespace flags) saw real, content-level
diffs. Classifier saw 92 modifications. Orchestrator bumped + tagged
+ pushed. Three phantom v1.0.1.0 releases on X33 in a row, each
reverted but each provoked by the next release call.

Fix: drop the Generated: timestamp line entirely. The git commit
history records when each file was committed; the in-file timestamp
was both redundant AND actively harmful (broke idempotency, defeated
the no-changes short-circuit in release_project_version).

Side effect: the next mirror_export against an existing project
will produce an N-file 'remove that timestamp line' commit -- a
one-time schema migration. Subsequent runs are then idempotent.

This bug took three rounds of recursive fork fixes to track down:
  9fb569b orphan-shutdown -- unrelated, real fix
  a570132 classifier --ignore-cr-at-eol -w -- defensive but
                                              not the root cause
  THIS    drop the timestamp -- root cause

Lesson: when a no-op call has side effects, look for content
non-determinism in the no-op itself before blaming the classifier.
2026-04-26 01:27:16 +02:00
Karstein Phobic Nyvold Kvistad
5cbd540fde bump_project_version: resume from GVL when Project Information missing
Previously, when a project had no Project Information node (e.g. one
created from the Standard template via create_project), the bump
flow read pi.version as None, the seed-check fired, and every call
re-seeded to 1.0.0.0 -- subsequent revision/minor/major bumps were
no-ops because the script never saw the actual current version.

Surfaced on MCPTest2 today: bumping revision after editing PLC_PRG
returned '1.0.0.0' instead of '1.0.1.0' because the seed kept firing.

Fix: when Project Information is missing, fall back to reading the
existing _MCP_PROJECT_VERSION.sVersion via the textual_declaration
of the GVL we ourselves maintain. So the source-of-truth chain is:

  pi.version (when Project Information exists)
    -> falls back to GVL.sVersion (when Project Information missing
                                    but the GVL has been written
                                    by a prior bump)
    -> falls back to seed at 1.0.0.0 (true first-run, no GVL yet)

Implementation: read_version_from_gvl(primary_project) walks the
active Application's children for the named GVL and parses the
sVersion := '...' literal out of its textual_declaration with a
4-part regex. Returns None if the GVL doesn't exist OR its decl
doesn't match the expected shape; caller treats None as "no prior
version, seed". Soft-fails on any access exception (the bump is
the primary outcome, this is just resume-from-state).

This is the kind of "every arising problem fixed at the fork, not
worked around in one-offs" hygiene the user called out.
2026-04-26 00:53:10 +02:00
Karstein Phobic Nyvold Kvistad
b07c24559e bump_project_version: handle projects with no Project Information node
Standard-template projects (those created via create_project) often
don't have a Project Information node at all -- it's added lazily by
the IDE when the user opens Project menu -> Project Information for
the first time. Surfaced when running auto-bump against MCPTest2
(copied from MCPTest, which was created via create_project).

Two changes:

1. Use the documented is_project_info marker (ScriptProjectInfoMarker
   per the SP22 stub Stubs/scriptengine/ScriptObject.pyi) to find the
   node, instead of name-matching 'Project Information'. Also robust
   against localised IDE display names ('Projektinformation' in DE).
   Walks up to depth 4 from the root.

2. If still no node found, log a WARNING and SKIP the metadata write
   (Project Information.Version), but continue with the GVL
   maintenance. The GVL is the runtime source-of-truth anyway -- the
   running PLC reads _MCP_PROJECT_VERSION.sVersion, not the .project
   metadata. Subsequent bumps after the user adds Project Information
   manually (Project menu -> Project Information in the IDE) will
   pick up both sides.

Output line is also adjusted -- 'Project Information.Version: (skipped
-- node missing) -> 1.0.0.0' instead of pretending to have updated
something that doesn't exist.
2026-04-26 00:44:43 +02:00
Karstein Phobic Nyvold Kvistad
e29bb7e534 feat(read_running_version_online): read _MCP_PROJECT_VERSION from running PLC
New MCP tool that reads the running project's version from a
connected PLC over the CODESYS online protocol (port 11740 / gateway).
Returns the value of `_MCP_PROJECT_VERSION.sVersion` -- the runtime
anchor that bump_project_version maintains automatically (commit
00d2dd8). Closes the loop on the version-tracking convention:

  bump_project_version           writes _MCP_PROJECT_VERSION.sVersion
                                 into the project (compiled into the
                                 boot application on next download)
  read_running_version_online    reads the same symbol back from the
                                 running PLC over the online protocol

Pairs with the existing connect_to_device + read_variable pattern
but with sharpened error messages tailored to the version-read use
case:
  - missing GVL                  -> 'has bump_project_version run on
                                     this project? Or has the boot
                                     application not been downloaded
                                     since the bump?'
  - read returns None            -> 'variable exists in project but
                                     not in boot app -- download_to_device
                                     after the last bump.'
  - missing online API           -> typed clearly, suggests SP-version
                                     drift.

Implementation: ensure_project_open + ensure_online_connection +
online_app.read_value('_MCP_PROJECT_VERSION.sVersion'); strips quote
characters from the returned STRING; sanity-checks the shape against
\b\d+\.\d+\.\d+\.\d+\b and warns if it doesn't match the
4-part convention. TS handler extracts the matched RUNNING_VERSION
line from the script output and surfaces it as the headline of the
tool response.

SSH transport variant (read_running_version_ssh) lands as a separate
later commit once a real PFC is reachable to test against.
2026-04-26 00:33:22 +02:00
Karstein Phobic Nyvold Kvistad
00d2dd8d96 bump_project_version: also maintain _MCP_PROJECT_VERSION GVL
Establishes the runtime-readable version anchor convention. Every
bump (manual or auto) now ALSO ensures the Application has a GVL
named '_MCP_PROJECT_VERSION' with:

  {attribute 'qualified_only'}
  VAR_GLOBAL CONSTANT
      sVersion : STRING := '<X.Y.Z.W>';
  END_VAR

Created on first bump; updated in place thereafter. Soft-fails if
the Application object can't be found or create_gvl() raises -- the
primary outcome (Project Information.Version updated and saved) has
already happened by the time GVL maintenance runs, so a GVL hiccup
is logged as a WARNING but doesn't fail the whole tool.

Why this matters:
  - Project Information.Version is metadata. The running PLC binary
    embeds it but exposing it at runtime requires the auto-generated
    Project_Info library helpers (GetVersion etc.), which not every
    project has wired up.
  - A plain VAR_GLOBAL CONSTANT in a known-name GVL is the simplest,
    most portable runtime anchor. Any IEC code can read it as
    `_MCP_PROJECT_VERSION.sVersion`. The future read_running_version_online
    tool will pull it via online connect + read_variable. The future
    SSH transport variant can pull it via libcmd-symbol-export or by
    grepping a debug log line that the project author can wire to
    write at startup.

qualified_only is set so the symbol can't accidentally shadow a
same-named local in user code.

The GVL convention will be exercised end-to-end on MCPTest running
on the local soft PLC (port 11740) once the read_running_version_online
tool ships -- that's the next ship in this sequence.
2026-04-26 00:31:26 +02:00
Karstein Phobic Nyvold Kvistad
e37a2191a9 list_project_libraries: capture + render project version + IDE + devices
Enriches the tool's output with project-level metadata that previously
lived only in hand-edited library.md headers:

  Project info:
    Version: 1.0.0.0          (Project Information.version)
    Title:   ...              (Project Information.title)
    Company: ...              (Project Information.company)
    Author:  ...              (Project Information.author)
  IDE:    CODESYS V3.5 SP22 Patch 1, ScriptEngine.plugin 4.2.0.0
  Devices (N):
    MainPLC                   [4096 / 1006 120D / 6.2.0.1]
    MainPLC/Kbus              [32778 / Wago 750-Series Local Bus Interface / 2.1.0.1]
    ...

Implementation:
  - collect_project_info() reads .version / .title / .company / .author
    on the Project Information node (first child of project root). Each
    field is read defensively (try/except) since some installs leave
    them unset; missing fields are dropped from the output.
  - collect_devices() walks the tree depth-first for nodes where
    is_device is True, captures get_device_identification() into a
    type/id/version triple. The triple is the offline target id the
    IDE uses to pick a compiler + runtime when building -- not the
    live firmware reported by a connected PLC over a runtime
    connection (the latter would require an online connect).
  - sys.version inside IronPython under CODESYS reports the IDE
    version directly (same string we see in ready.signal).

server.ts renders these as a Header block above the existing
library-by-container tables. Hand-edited X33/library.md "Versions"
section is now redundant -- next regeneration will produce the
header automatically.

Verified via the local SP22 install + the live X33 watcher: pi.version
read-back works after bump_project_version sets it, devices walk
returns 13 entries on X33 (MainPLC + 11 Kbus modules + the network
adapter).
2026-04-26 00:12:06 +02:00
Karstein Phobic Nyvold Kvistad
75d77e2fb2 bump_project_version: seed at 1.0.0.0 on first run
Per user feedback. Previous behaviour treated 'no version yet' as
0.0.0.0 and bumped from there, so the very first call with level=build
produced 0.0.0.1 -- awkward as a canonical starting point. Most
projects start tracking at 1.0.0.0 the moment they turn on versioning.

New behaviour: if Project Information.version is None / empty / '0.0.0.0',
the tool seeds the value at 1.0.0.0 directly and ignores the level
argument for that one call. Subsequent calls bump per the level as
before.

Test cases:
  None     + level=build    -> 1.0.0.0  (seed)
  None     + level=major    -> 1.0.0.0  (seed)
  ''       + level=minor    -> 1.0.0.0  (seed)
  0.0.0.0  + level=revision -> 1.0.0.0  (seed)
  1.0.0.0  + level=build    -> 1.0.0.1
  1.0.0.0  + level=minor    -> 1.1.0.0
  1.2.3.4  + level=major    -> 2.0.0.0
2026-04-26 00:08:26 +02:00
Karstein Phobic Nyvold Kvistad
2ed3f17dc7 feat(bump_project_version): bump Project Information.Version one part
New MCP tool that increments one part of the 4-part
Project Information.Version field of the primary project, saves the
project, and reports the before/after.

Behaviour:
  - level=major   -> bump major,    reset minor/revision/build to 0
  - level=minor   -> bump minor,    reset revision/build to 0
  - level=revision-> bump revision, reset build to 0
  - level=build   -> bump build only

Convention follows the rest of CODESYS / 3S / WAGO library practice
(visible in any X33 library reference like 'WagoAppCanLayer2,
1.6.1.4 (WAGO)'):
  Major     -- incompatible API break (rename FB, change public
               signature, remove method).
  Minor     -- backward-compatible feature add.
  Revision  -- bug fix only, no API change.
  Build     -- internal / CI counter, often 0 for hand-released.

Implementation notes:
  - Project Information lives as the first child node of the project
    root. Its .version property is read/written directly; IronPython
    coerces strings like '1.2.3.4' to a System.Version on assignment,
    str(System.Version) gives the dotted form back. None / empty /
    unset is treated as '0.0.0.0'.
  - Verified live against X33 (MRCodesysX33_0021): set version to
    '1.0.0.0' from None, project.save() persisted it; reload via
    primary_project.get_children() found the same value. Probe done
    via the inject-once.mjs bridge against the live watcher in PID
    23056 before this commit landed.
  - project.save() is called after the bump so the new value sticks
    in the .project file. Soft-fails on save error (visible WARNING
    in DEBUG output but the bump itself is still reported as
    successful) so a save permission glitch doesn't mask the actual
    version change.

Used by the X33 GitLab project's "version in README header" workflow:
the version surfaces at the top of README.md (and at the top of the
library list once list_project_libraries gets enriched in a follow-up
commit).
2026-04-26 00:07:36 +02:00
Karstein Phobic Nyvold Kvistad
76b7cf485a feat(mirror_export): switch mirror file extension to .st
Match the de-facto community convention. .st (IEC 61131-3 Structured
Text) is what the existing PLC tooling around forge.codesys.com,
ArthurkaX/cds-text-sync, and the VS Code "IEC Structured Text"
extension already syntax-highlight by default. The previous .iecst
was specific but unhelpful: nothing else in the toolchain knew what
to do with it.

User asked for the switch right after Phase 1 landed (commit 7a6e725).
Re-ran the export against MRCodesysX33_0021 (X33) on disk: 91 files
re-emitted as .st, identical content + tree shape, total 254 KB.
2026-04-25 22:59:25 +02:00
Karstein Phobic Nyvold Kvistad
7a6e7254a6 feat(mirror_export): write the project tree out as a browseable .iecst mirror
Phase 1 of the "CODESYS project as a filesystem you can edit + diff +
ai-tooling against" idea. Today the only way to read code in a project
is via a one-shot get_all_pou_code dump (one giant JSON) or by clicking
through the IDE; neither is friendly to AI-assisted edits, code review,
git diffs or external tooling. mirror_export walks the live project
tree and emits one .iecst file per code-bearing object, preserving the
project's folder structure as nested directories on disk.

What the tool does:

  - Walks every node from script_engine.projects.primary.get_children()
    recursively (depth-first).
  - Structural nodes (Device, Application, Folder, ...) become
    directories under MIRROR_ROOT.
  - Code-bearing nodes (Program, FB, Function, Method, Property, DUT,
    GVL, Interface, ...) become <name>.iecst files in their parent
    directory; if a code-bearing node has child code objects (e.g. an
    FB with methods), those children land in a sibling subdirectory
    with the parent's name.
  - File header: `(* === CODESYS export -- KIND === *)` + project path
    + generated timestamp, so a future write-back tool can map each
    file back to set_pou_code's pouPath.
  - Body: declaration block, then `(* === IMPLEMENTATION === *)`
    separator (when both are present), then implementation block.

Defaults the mirror root to `<projectDir>/MCP/mirror` so it lands next
to the existing library.md / pou-dump.md if the user has been
following the same folder convention.

Implementation details that would have bitten without testing:

  - UTF-8 output via codecs.open. CODESYS POU text occasionally
    contains non-ASCII (smart quotes from copy-paste, degree signs,
    etc.). IronPython 2.7's builtin open() defaults to ASCII and
    would raise; saw it on X33's ST_HetronicIn2 (smart quote) until
    fixed.
  - Filesystem-illegal characters in CODESYS object names (`/`, `\`,
    `<>:"|?*`) replaced with `_`. CODESYS lets you name a folder
    "Remote / Hetronic"; on Windows that splits as two folders with
    naive os.path.join.
  - Kind classifier strips leading `//` and `(* *)` comments and
    `{attribute := '...'}` pragmas before matching the IEC keyword,
    otherwise GVLs decorated with attribute pragmas got bucketed as
    OTHER (X33 had 2 of these; first try misclassified them).

Verified against MRCodesysX33_0021 (X33): 91 files, 254 KB, 0 errors,
preserves the full tree (MainPLC/Plc Logic/Application/MRLib/...).

Phase 2 (write-back via a `sync_pou_from_file` tool that reads a
mirror file, splits decl/impl on the IMPLEMENTATION separator, and
calls set_pou_code) is out of scope for this commit.
2026-04-25 22:33:50 +02:00
Karstein Phobic Nyvold Kvistad
9b766c8b6b fix(list_project_libraries): use ScriptLibManObjectContainer API
RTFM. Per the helpme-codesys.com Library Manager scripting page and
the local SP22 stub Stubs/scriptengine/ScriptLibManObject.pyi:

- ScriptLibManObjectContainer is added to BOTH the project AND every
  Application object. It exposes:
    has_library_manager   -- @property (NOT a method) returning bool
    get_library_manager() -- method returning the LibMan ScriptObject
- ScriptLibManObject (the libman itself) exposes:
    .references            -- @property, ScriptLibraryReferences (list-
                              like) of ScriptLibraryReference objects
                              with structured fields (name, namespace,
                              is_placeholder, is_managed, system_library,
                              effective_resolution, ...)
    get_libraries(recursive=False)  -- list[str], names only
- ScriptLibManObjectMarker.is_libman is the universal marker, useful
  as a fallback when walking the tree.

The previous implementation searched the project tree by NAME for an
object literally called "Library Manager" via primary_project.find()
and a children name probe. That never matches because the libman's
actual name is generated, not "Library Manager." On the X33 project
(MRCodesysX33_0021) it returned empty -- false negative on a project
that obviously has dozens of system + application libraries. This
also took add_library down with it (same wrong axis), and the smoke
test from earlier in the fork's history flagged the inconsistency
without identifying the cause.

Fixed:
- Walk the tree depth-first, accept any node where has_library_manager
  is True (depth-limited at 8 to be safe).
- For each, call get_library_manager() and iterate .references for
  structured data; fall back to get_libraries() name-only enumeration
  if .references is unavailable on the SP.
- Capture every documented ScriptLibraryReference field defensively
  (each access wrapped in try/except since some fields raise on
  placeholders / unmanaged / SP-version skew).
- Return a structured JSON shape grouped by container so the TS side
  can show which Application owns which libraries.

server.ts:
- Updated the result-parsing block to handle the new structured shape.
- Distinguishes "no library managers found" (suspicious, libman
  discovery probably broken) from "found managers, all empty" (just
  empty applications).
- Renders flags ([system, placeholder, managed, optional, redirected])
  + namespace + effective_resolution per reference.
- Tool description rewritten to advertise the actual mechanism and
  cite the doc + local stub source.

add_library is NOT fixed in this commit -- it has the same wrong-axis
bug but landing the lookup-only fix first to verify the API contract.
add_library will land separately once we know list_project_libraries
sees the right libman in production (X33 smoke test).
2026-04-25 21:59:23 +02:00
Karstein Phobic Nyvold Kvistad
e6cfa5730b fix(git_*): persist binding/state with project.save() after every mutating op
Second RTFM remediation. The helpme-codesys.com Git scripting page
(https://content.helpme-codesys.com/en/CODESYS%20Git/_git_using_scripting.html)
says explicitly:

  "Project Save: Call project.save() after operations to persist
   changes. Appears after operations like push() and merge() to
   persist changes."

The example flows on that page show project.save() interleaved with
init / commit / push / merge. None of the git_* wrappers shipped so
far called save(), which means binding info, configured remotes,
upstream tracking, and post-commit state could fail to persist when
the IDE closes -- silently degrading every flow that spans more than
one CODESYS session.

This commit adds a soft-fail primary_project.save() after every
mutating op:

  git_init                    after git.init(...)
  git_commit                  after git.commit_complete(...)
  git_remote_add              after git.remote_add(...)
  git_branch_set_upstream_to  after git.branch_set_upstream_to(...)
  git_push                    after git.push(...)

git_status is unchanged -- it's read-only.

Soft-fail rationale: a save() failure does NOT undo a successful git
op. We log a WARNING and continue, so the visible result still
reflects what actually happened in the repo. Bubbling save errors
would risk telling the user "init failed" when in fact the .git/ is
on disk and the only loss is the in-memory binding. The honest
failure mode is "git op succeeded, persistence may not have."
2026-04-25 20:46:57 +02:00
Karstein Phobic Nyvold Kvistad
e3e5f58039 feat(git_branch_set_upstream_to): ship the missing canonical step
RTFM. The helpme-codesys.com Git scripting page
(https://content.helpme-codesys.com/en/CODESYS%20Git/_git_using_scripting.html)
spells out the canonical "init -> commit -> remote_add -> push"
sequence and explicitly inserts a mandatory step between remote_add
and push:

  "Set Upstream Before Push: After adding a remote, use
   project.git.branch_set_upstream_to(origin_remote) before pushing."

The previous commit (8a6059b) shipped git_remote_add + git_push but
left this step labelled "out of scope," which guaranteed the very
first end-to-end smoke test would fail with:

  sLocalBranchName: The branch 'master' ('refs/heads/master')
  does not track an upstream branch.

Confirmed against the GitSmokeTest project (C:\Temp\GitSmokeTest)
on 2026-04-25 immediately after the commit landed.

This commit adds git_branch_set_upstream_to as a thin wrapper over
project.git.branch_set_upstream_to(remoteName, branchName?) using
the simplest of the four overloads in
Stubs/scriptengine/GitScriptProject.pyi. branchName defaults to the
current branch (per the stub default arg). Defensive checks +
HasGitLicense rewrite mirror the rest of the git_* tool family.

Tool description carries the doc citation up front and the exact
error message you get without it, so the next user sees the missing
step before having to discover it experimentally.
2026-04-25 20:45:43 +02:00
Karstein Phobic Nyvold Kvistad
8a6059b9a0 feat(git_*): add git_remote_add and git_push wrappers
Two new MCP tools wrapping the remaining pieces needed for an
end-to-end "init -> commit -> push to GitLab" flow:

  git_remote_add   Wraps project.git.remote_add(name, url). Required:
                   remoteName + remoteUrl. Conventionally name='origin'
                   for the primary upstream.

  git_push         Wraps project.git.push(...). Three overloads handled:
                   - push() when no branch + no creds (relies on tracked
                     upstream + git config / Windows Credential Manager)
                   - push(branch) when only branch is provided
                   - push(branch, user, SecureString(token)) when both
                     credentials are provided; derives current branch via
                     branch_show_current() if branchName is omitted.

Credentials handling: when a token is supplied it is converted to
System.Security.SecureString before being handed to push(), per the
CODESYS Git scripting docs guidance ("Use SecureString passwords
whenever possible"). Tool description carries an honest security note
that the token is briefly templated into the IronPython source that the
watcher executes -- prefer cached credentials when feasible. server.ts
neutralises backslashes/double-quotes and rejects newlines in the
templated values up front.

Both tools share the same defensive checks as the existing git_* trio:
- hasattr(script_engine, 'git') / project.git is None handling so the
  user gets a "run git_init first" hint when the project is unbound.
- attribute probe + dir() dump if the API surface is missing the
  expected method.
- HasGitLicense detection in the catch-all that rewrites SCRIPT_ERROR
  into the clear "PDE subscription required" message.

API contract verified against:
- Stubs/scriptengine/GitScriptProject.pyi (local SP22 install) -- the
  remote_add and push overload set.
- helpme-codesys.com Git scripting page
  (https://content.helpme-codesys.com/en/CODESYS%20Git/_git_using_scripting.html)
  -- SecureString recommendation for password params.

Out of scope: pull, fetch, branch_set_upstream_to, push_delete,
clone (top-level script_engine.git, not project-bound). Can land
separately.
2026-04-25 20:24:15 +02:00
Karstein Phobic Nyvold Kvistad
31e842929e fix(git_init): default to sibling _git dir, auto-create, clear empty check
CODESYS Git uses a dual-storage model: the .project file stays where
it is, the git working tree lives in a SEPARATE empty directory. The
prior default ("init in the project's own folder") tripped over the
project file itself and CODESYS would raise the unhelpful
'gitProjectStoragePath: ... is not a valid Git repository location:
DirectoryNotEmpty'. Hit this end-to-end during the first PDE-Demo
smoke test on 2026-04-25 -- the user had to dance around it manually.

git_init now:
- Defaults LocalRepoPath to '<project_basename>_git' as a sibling of
  the project's own dir when not supplied (or when supplied equal to
  the project dir, which has the same trap).
- Auto-creates the target dir if missing.
- Pre-validates emptiness and raises a clear, action-oriented error
  if non-empty, instead of letting CODESYS surface DirectoryNotEmpty.

server.ts tool description updated to call out the dual-storage rule
and the auto-default behaviour up front.

Verified API contract via Stubs/scriptengine/GitScriptProject.pyi
(local SP22 install) and the helpme-codesys.com Git scripting page
(https://content.helpme-codesys.com/en/CODESYS%20Git/_git_using_scripting.html).
2026-04-25 20:22:10 +02:00
Karstein Phobic Nyvold Kvistad
3623c45d31 feat(git_*): hard-stop with clear PDE-subscription message on HasGitLicense
The 3 git_* tools previously surfaced the raw CODESYS exception
"Permission denied: Rule 'HasGitLicense' failed with state 'False'."
which is opaque to anyone who does not already know that CODESYS Git
scripting is gated behind the Professional Developer Edition
subscription. Verified 2026-04-25 on this workstation that the
plug-in is installed (PlugIns/GitIntegration.plugin.dll v1.7.0.0,
ScriptDriverGit.plugin.dll, full ScriptLib/Stubs/scriptengine/Git*.pyi
set, and the Git menu visible in the IDE) but the runtime rule still
returns False because no PDE subscription is active. Per the CODESYS
Git store page the subscription is the actual gate (Additional
Requirements / Licensing).

Each script now detects 'HasGitLicense' in the exception or traceback
and rewrites SCRIPT_ERROR to a clean, actionable message pointing at
https://store.codesys.com/en/codesys-git.html with a "what to activate"
hint, falling back to the original generic error formatting on any
non-license failure.

git_status: also adds an early license probe via project.git.has_working_tree()
(license-gated per the SP22 stubs) so the rewrite triggers reliably
instead of being swallowed by the per-method probe loop further down.

Tool descriptions in server.ts updated to advertise the PDE
subscription requirement up front, so MCP clients see it without
having to fail first.
2026-04-25 19:25:58 +02:00
Karstein Phobic Nyvold Kvistad
e236a0cfad feat: add git_status / git_init / git_commit MCP tools (CODESYS Git plug-in)
Cross-references the official Git scripting docs at
https://content.helpme-codesys.com/en/CODESYS%20Git/_git_using_scripting.html
which exposes a project-bound git API as 'primary_project.git' (when the
CODESYS Git plug-in is loaded). Confirmed earlier today via dir(scriptengine)
that 'git' is one of the top-level scriptengine modules on this install.

Three tools in this commit, ordered shallow-to-deep:

  git_status        Read-only. Returns current branch via
                    project.git.branch_show_current() AND a defensive probe
                    of any status/changes/diff/changed_files methods on
                    project.git (the docs page lists the call patterns by
                    example but does not enumerate the full API surface, so
                    the probe + diagnostic dump is how we'll discover the
                    rest in the next iteration).

  git_init          Wraps project.git.init(local_repo_path). Defaults the
                    repo path to the project file's parent directory so a
                    plain 'init this project's folder' call needs no extra
                    args. One-shot setup; pair with git_status afterwards.

  git_commit        Wraps project.git.commit_complete(message, user, mail).
                    Required: message + authorName + authorEmail (latter
                    validated as email by zod). Stages all working-tree
                    changes and commits in one shot per the docs. Multi-line
                    messages handled via triple-quoted Python injection with
                    standard backslash + triple-quote escaping.

Defensive checks across all three:
  - hasattr(script_engine, 'git') so we can distinguish "Git plug-in not
    installed" from "project not in a repo".
  - project.git is None handled with a clear 'run git_init first' message.
  - On missing methods the script dumps sorted(dir(project.git)) so the
    next debug session sees the exact surface.

Out of scope for this commit (deliberate -- one feature per commit per
the project rule, more git ops can land separately):
  - branch ops (branch_copy, checkout)
  - remote ops (remote_add, push, pull, fetch, branch_set_upstream_to)
  - merge
  - clone (script_engine.git.clone -- top-level, not project-bound)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:01:09 +02:00
Karstein Phobic Nyvold Kvistad
b3bf4a83d5 fix(download_to_device): port login() probe + loginWaitSeconds from connect
Same SP21+/SP22 login() drift as connect_to_device:
  - 'TryOnlineChange' is gone from OnlineChangeOption
  - login() now requires (OnlineChangeOption, bool) -- two positional
The fork hard-coded the old call shape; observed today on SP22 Patch 1
with the runtime up and the project compiled, every download_to_device
attempt failed with "login() takes exactly 2 arguments (0 given)".

Fix mirrors e862846 / eee8ce2:
  - Probe OnlineChangeOption members at runtime (priority order tuned
    for download: WithDownload / ForceDownload come first, since
    'download' implies a write).
  - Try (enum, False) / (enum, True) / (enum,) / bool / no-arg shapes.
  - Add LOGIN_WAIT_SECONDS post-login state-stabilisation poll for the
    credential dialog (default 60s, configurable 0-600).
  - Tool-side IPC timeout = waitSec + 120s headroom for the actual
    download work (download is heavier than connect, hence 120 vs 30).

The download itself is still done via the existing fall-through:
online_app.download() if exposed, else create_boot_application().
On SP22 the dir() shows source_download + create_boot_application (no
'download'), so the create_boot_application path is what runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:40:26 +02:00
Karstein Phobic Nyvold Kvistad
64906c4ab5 fix(write_variable): use SP22 prepare-then-write API as primary path
After 010811b's diagnostic dump revealed the actual online_app surface on
SP22 Patch 1:

  ['Dispose', 'application', 'application_state', 'create_boot_application',
   'force_prepared_values', 'get_forced_expressions', 'get_online_device',
   'get_prepared_expressions', 'get_prepared_value', 'is_logged_in', 'login',
   'logout', 'operation_state', 'read_value', 'read_values', 'reset',
   'set_prepared_value', 'set_unforce_value', 'source_download', 'start',
   'stop', 'timeout', 'unforce_all_values', 'write_prepared_values']

There is no direct write_value / write / set_value method. The supported
pattern is two-step:

    online_app.set_prepared_value(path, value)   # stage
    online_app.write_prepared_values()           # commit

Asymmetric to read_value() (which is direct), but it's what the SP21+/SP22
scriptengine surface exposes.

This commit:
  - Makes the prepare-then-write path the primary code path.
  - Keeps the direct write_value / set_value / write / set fallbacks for
    older SPs that still expose them (probe order: prepare-first, then
    direct).
  - Falls back to dumping dir(online_app) on total failure, same diagnostic
    pattern that revealed this API in the first place.

Note for future reference: 'force_prepared_values()' is the alternate
commit method when you want to FORCE a value (override what the program
will write next cycle), versus 'write_prepared_values()' which is a normal
one-shot write.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:37:38 +02:00
Karstein Phobic Nyvold Kvistad
010811b342 fix(write_variable): probe write methods + diagnostic dump on miss
Mirror of the connect_to_device probe pattern (e862846). The fork's
prior write_variable.py hard-coded write_value() then write() and
errored if neither existed; on SP22 Patch 1 neither is exposed on
online_app, even though the read counterpart (read_value()) works.

Now tries six method names in priority order:
  - Single-write: write_value, set_value, write, set
  - Batch-write:  write_values, set_values   (passes [(name, value)])

On total miss, dumps sorted dir(online_app) so the next debug session
sees exactly what the live online application object exposes -- the
same diagnostic technique that found 'librarymanager' in the earlier
install_library_file probe.

read_variable already works (uses read_value()) so the asymmetry is
specifically on the write side. Verifying by re-running write_variable
after the MCP restart will reveal which method actually exists, and
we can pin it explicitly in a follow-up if useful.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:32:57 +02:00
Karstein Phobic Nyvold Kvistad
eee8ce2d1e feat(connect_to_device): add loginWaitSeconds for credential dialog
When connecting to a password-protected runtime for the first time,
CODESYS pops a modal credential dialog. login() in some SP versions
returns immediately (without waiting for the dialog), leaving the
application in an undefined state until the user fills it in.

Behaviour:
  - New optional 'loginWaitSeconds' parameter on the tool (default 60,
    range 0-600). After login() returns, the script polls
    online_app.application_state once per second up to that many
    seconds, exiting early when the state lands on a recognisable
    value: run / stop / connected / halt / breakpoint.
  - During the poll, system.delay(1000) pumps the UI message loop so
    the dialog renders and stays interactive while we wait. Once the
    user enters the password and clicks OK, login completes, state
    transitions, and the loop exits.
  - Tool-side IPC timeout extends accordingly (waitSec + 30s headroom)
    so the IPC layer doesn't kill the script mid-dialog.

Concrete case observed today on SP22 Patch 1: prior connect appeared
to "succeed" only because the user happened to be at the keyboard and
manually entered the password while the dialog was up. Without the
poll, an unattended run would race past the dialog into a half-state.

Future companion: download_to_device hits the same login path and
should get the same parameter; will follow as a separate commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:31:56 +02:00
Karstein Phobic Nyvold Kvistad
e862846164 fix(connect_to_device): probe OnlineChangeOption + try login() call shapes
Defensively iterates plausible signatures for IOnlineApplication.login()
across SP versions. Prior version hard-coded
'login(OnlineChangeOption.TryOnlineChange)' which fails on SP21+/SP22:

  - 'TryOnlineChange' enum member was removed
  - login() now requires (OnlineChangeOption, bool) -- two positional args

Concrete failure observed today on SP22 Patch 1:
  - First fallback raised: 'type' object has no attribute 'TryOnlineChange'
  - Second fallback raised: login() takes exactly 2 arguments (0 given)
  ...with the runtime actually reachable on port 11740. So a real call
  was waiting for the right arguments.

New approach:
  1) Discover OnlineChangeOption members at runtime via dir(); print them
     so future debug sessions see exactly what's exposed on this CODESYS.
  2) Try (enum, False) / (enum, True) / (enum,) for each candidate enum
     value, prioritising 'Try'-ish names then 'WithDownload' / others.
  3) Fall back to bool-only and no-arg for very old SPs.
  4) Log every attempt (DEBUG line) so a failure trace shows the full
     matrix that was tried.

Companion fixes for download_to_device, write_variable, etc. coming as
separate commits if their root cause is the same login API drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:16:54 +02:00
Karstein Phobic Nyvold Kvistad
d7a263b5d1 revert: remove library install/update MCP tools (out of scope, fragile)
Removes the five MCP tools added across 1f23cb0..a0a4945 plus the
helpers and Node-side imports that supported them:

  - install_library_file        (1f23cb0, a0a4945)
  - install_library_from_url    (7fcca42)
  - install_addon_from_file     (79c83fd, 33f494e)
  - update_all_libraries        (9a4e96f)
  - set_library_version         (3832f9c)

  Plus src/scripts/{install_library_file,update_all_libraries,
  set_library_version}.py and the downloadToTempFile / runProcess /
  locateAPInstallerCli helpers in src/server.ts.

Reasons (decided 2026-04-25 after the WAGO bring-up attempt):

  * Library install via scriptengine kept hitting an evolving API
    surface across SP versions. Even after probing the actual SP22
    Patch 1 surface (which exposes 'librarymanager', not 'libraries'),
    the install methods on that object were not yet validated and
    the iteration would have continued.

  * .package install via APInstaller.CLI requires admin rights to
    write under Program Files. The MCP server runs at user level
    and the user is on a workstation without full admin, so this
    path is dead end without an elevation strategy that doesn't
    exist for headless agent use.

  * The right tool for system-library auto-download is the in-IDE
    'Download missing libraries' dialog (which works -- only the
    WAGO subtree is broken because WAGO pulled it from the Store
    archive). For .package bundles, the right tool is the standalone
    CODESYS Installer GUI which handles UAC properly. Wrapping
    those in MCP added complexity without solving the underlying
    permission/scope problems.

The fork's substantive value is the SP21+/SP22 watcher rewrite and
KeyboardInterrupt hardening -- keeping focus there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:09:11 +02:00
Karstein Phobic Nyvold Kvistad
a0a4945d00 fix(install_library_file): add scriptengine.librarymanager to API discovery
Empirical finding from this CODESYS install (SP22 Patch 1, ScriptLib
4.1.0.0): the library-management entry point is exposed as
'scriptengine.librarymanager' (single lowercase word). The previously
tried paths -- 'libraries', 'Libraries', 'system.libraries',
'library_repository' -- are all absent. Confirmed by dumping
sorted([a for a in dir(scriptengine) if not a.startswith('_')]) which
lists 'librarymanager' alongside other repository-style modules
('device_repository', 'modulerepository', 'visuelemrepository').

This commit just adds 'librarymanager', 'LibraryManager', and
'library_manager' to the candidate-attribute list at the top of the
defensive probe. Existing fallback (iterate .repositories) and method
detection (install_library / install / add_library / add) are unchanged
and should pick up from there once we know how librarymanager exposes
its repositories on this version.

Pre-existing diagnostic dump on failure stays in place -- if librarymanager
exists but doesn't expose any of the expected install methods, the next
failure will print dir(librarymanager) directly so we can iterate again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:06:33 +02:00
Karstein Phobic Nyvold Kvistad
3832f9c5cc feat: add set_library_version MCP tool for surgical per-library pins
Companion to update_all_libraries (9a4e96f). Where update_all_libraries
rewrites every reference in one shot, this tool targets a single library
by name -- intended for surgical fixes where most refs are fine but one
is broken. Concrete use case: X33's StringUtils pinned to 3.5.18.0
(non-resolvable) needs to move to 3.5.20.0 specifically without touching
the dozens of other references.

  - src/scripts/set_library_version.py:
      Same defensive multi-pattern enumeration + setter probing as
      update_all_libraries. Looks up the target ref by case-insensitive
      bare name match, or 'Namespace.Name' if the user disambiguates.
      Returns ValueError listing all available names if no match, and a
      separate ValueError listing namespace.name pairs if the bare name
      matches more than one ref. Falls back to remove + re-add when the
      Library Manager exposes no direct version setter on the ref.

  - src/server.ts:
      Wires as 'set_library_version' with required projectFilePath,
      libraryName, and targetVersion. Saves the project on success.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:26:35 +02:00
Karstein Phobic Nyvold Kvistad
9a4e96f11a feat: add update_all_libraries MCP tool to bulk-rewrite library versions
Walks the project's Library Manager and rewrites every library reference's
version to a single target value (default '*' = always-newest installed).
The immediate motivating case: X33 has a StringUtils 3.5.18.0 pin that
no longer resolves on SP21/SP22 boxes, plus several other version-pinned
references that should track the locally-installed copies.

  - src/scripts/update_all_libraries.py:
      Defensive multi-pattern enumeration -- ScriptLibraryManager exposes
      different ref-listing methods across SP versions (get_all_libraries
      / get_libraries / get_references / iteration). Same for the per-ref
      version-mutation API: set_version / update_to_version / update /
      set_resolution. Falls back to remove + re-add if no in-place setter
      is exposed.
      Skips refs flagged is_system unless INCLUDE_SYSTEM=True (system
      pins are usually device-tied and changing them breaks the project).
      Emits a per-library before/after table; a single failure makes the
      whole tool report SCRIPT_ERROR so partial-update states are loud.

  - src/server.ts:
      Wires as 'update_all_libraries' with optional targetVersion (default
      '*') and includeSystem (default false). Uses ensure_project_open so
      the tool works against any project on disk, not just the currently
      loaded one.

Companion 'set_library_version' (single-library surgical change) coming
in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:25:33 +02:00
Karstein Phobic Nyvold Kvistad
1f23cb0498 feat: add install_library_file MCP tool for system-wide library installs
Adds a new tool that installs a .library file into the CODESYS Library
Repository via the scriptengine API, independent of any open project.
Useful for automating library bring-up on a fresh machine when the
CODESYS Store auto-download is broken (the immediate motivation: WAGO
libraries 404 against store.codesys.com / store-archive.codesys.com).

  - src/scripts/install_library_file.py:
      Single LIBRARY_FILE_PATH parameter. Defensive multi-pattern API
      probe -- libraries can be reached via either script_engine.libraries
      or script_engine.system.libraries depending on SP19/SP21/SP22, and
      the install method may be install_library / install / add_library /
      add with either (path,) or (path, overwrite) signatures. Picks the
      first writable (non-system) repository it finds. Reports installed
      name + version + repository on success.

  - src/server.ts:
      Wires the script as MCP tool 'install_library_file' immediately
      after add_library. Does NOT use the ensure_project_open helper,
      because repository installs work without a project loaded.

ASCII-only source (per the IronPython 2.7 lesson from 0.4.1).
Verification pending -- needs a real .library file to install.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:52:04 +02:00
Karstein Phobic Nyvold Kvistad
f1ea6e446f fix(watcher): use ASCII-only comments/log strings (IronPython 2.7 syntax error)
0.4.1 used em-dashes (--) in three log strings and one docstring line.
IronPython 2.7 (CODESYS scripting host) rejects non-ASCII bytes in a
source file unless an explicit encoding declaration (PEP 263) is present:

  Microsoft.Scripting.SyntaxErrorException: Non-ASCII character '\xe2' in
  file ...\watcher.py on line 225, but no encoding declared

Replaced em-dashes with ASCII '--' and bumped WATCHER_VERSION to 0.4.2.
File is now ASCII-clean throughout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:10:21 +02:00
Karstein Phobic Nyvold Kvistad
93a105a644 fix(watcher): swallow KeyboardInterrupt so CODESYS Cancel link doesn't crash the watcher
When the user clicks "Click here to CANCEL this operation" in CODESYS,
IronPython injects KeyboardInterrupt into the running script. The watcher's
try/except blocks only caught Exception, which doesn't include
KeyboardInterrupt — so the interrupt propagated to the host and CODESYS
popped the modal "Running script ... caused exception ... KeyboardInterrupt:
Script aborted by user." dialog. The watcher process then died, taking the
MCP IPC channel with it.

Three layers of handling:

  1. execute_script(): catch KeyboardInterrupt and convert it into a normal
     command failure ("Aborted by user (Cancel pressed in CODESYS)") so the
     in-flight command fails gracefully and the loop continues.

  2. Main poll loop: catch KeyboardInterrupt around the iteration body, log
     it, and continue. New _safe_delay() helper wraps system.delay() with
     its own KeyboardInterrupt swallow because the cancel link almost always
     hits during the delay (line 231 in 0.4.0).

  3. Outer try: explicit KeyboardInterrupt arm so a cancel that fires before
     the loop (during scriptengine import or directory setup) still exits
     quietly without the modal traceback dialog.

Bumps WATCHER_VERSION to 0.4.1. Verification pending — needs user to
click the Cancel link in CODESYS after a fresh launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:05:35 +02:00
Karstein Phobic Nyvold Kvistad
f525da973a fix: drop bg-thread + execute_on_primary_thread for SP21+/SP22
Replace the .NET background-thread + system.execute_on_primary_thread
marshalling design with a single-threaded primary-thread polling loop
that yields to the IDE via system.delay(). The deprecated
execute_on_primary_thread API was removed in CODESYS V3.5 SP21+ — see
docs/MIGRATION-SP21-PLUS.md for full evidence and rationale.

- watcher.py: drop clr / System.Threading imports, drop ManualResetEvent
  + done_event + shared_result cross-thread machinery, keep file-based
  IPC and OutputCapture untouched.
- Loop checks for terminate.signal at the top of each iteration, then
  serves the message loop via system.delay(POLL_INTERVAL).
- Bumped WATCHER_VERSION to 0.4.0.

Verified against:
- CODESYS 3.5.22.10 (SP22 Patch 1) — open_project + get_application_state
  both succeed where they previously failed with the marshal error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:27:54 +02:00
Luke
aad246576c Add 17 new MCP tools for v0.4.0: compiler diagnostics, project authoring, runtime monitoring, library management
- Phase 1: Structured compiler diagnostics — compile_project now returns parsed errors/warnings with object name and line number; new get_compile_messages tool reads last build messages without recompiling
- Phase 2: Project authoring — create_dut, create_gvl, create_folder, delete_object, rename_object, get_all_pou_code
- Phase 3: Online/runtime — connect_to_device, disconnect_from_device, get_application_state, read_variable, write_variable, download_to_device, start_stop_application (with ensure_online_connection helper)
- Phase 4: Library management — list_project_libraries, add_library
- Bump version to 0.4.0, update README with full tool reference

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 21:05:12 +10:00
Luke
e374519fe1 Initial release: MCP server for CODESYS with persistent UI instance
v0.3.0 returning-watcher architecture — background thread polls for commands
and marshals execution onto the CODESYS UI thread, keeping the IDE fully
responsive between operations. File-based IPC with atomic writes, async mutex
command serialization, headless fallback, and 35 passing tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:59:16 +10:00