After printing the .mcp.json snippet, also print:
- Project-scoped path: <project>/.mcp.json (recommended, git-shareable)
- User-scoped path: %USERPROFILE%/.claude.json (resolved for the current user)
- 'claude mcp add' CLI alternative
- Restart-Claude-Code reminder
The path interpolation uses USERPROFILE so the printed path matches
the user's actual home, not a generic placeholder.
Runs after `npm install -g codesys-mcp-sp21-plus`. Detects every
CODESYS install on PATH and prints the ready-to-paste .mcp.json
block per install (same output as `--print-config`).
Guards:
- Skipped during local installs / dev clones (npm_config_global != true)
- Skipped in CI (CI=true or npm_config_ci=true)
- Wrapped in try/catch + 'node ... || true' so a banner failure never
blocks the install
- Non-Windows: prints a note and exits cleanly
- Zero CODESYS installs: prints a hint pointing at --print-config
Resolves the awkward 'now run these two commands to verify and get
your config' step from the README.
New flags:
- --print-config: scan installs and emit a JSON block per install with
derived server names (codesys-sp21-patch5, codesys-sp22-patch1, etc.)
- --sp <n>: filter to one SP family; collapses entry name to 'codesys'
when exactly one install matches
- --name <name>: override the entry name (only valid with --sp narrowing
to one)
Side effect: --detect now reuses the same detector and additionally
prints the derived profile name + suggested server entry name per
install, so even users sticking to manual config get the values
without guessing.
Refactored install discovery into src/detect.ts so both --detect and
--print-config share one implementation. New unit test fixture covers
version parsing, missing-exe, dedup, sort order, --sp filter behaviour,
--name override constraints, and verifies the emitted JSON parses back
once // comments are stripped.
The output also surfaces the multi-install caveat from launcher.ts:
the double-spawn guard refuses to start a second CODESYS.exe even on
a different exe path, so only one configured entry can be active at
a time.
Branch was deleted after main caught up. server.ts auto-generates
library.md and pou-dump.md headers in user projects, so the broken
URL was leaking into every consumer of those tools. Now points at
the repo root (main is the only branch).
- Lead with a clear "this is a fork" callout (upstream link, fork link, npm link, maintainer)
- New "What's new in this fork" section grouping all changes:
* Compatibility fixes (SP21+ watcher rewrite, KeyboardInterrupt cancel hardening)
* Upstream tool fixes (create_folder, compile JSON long, connect_to_device LoginMode + auto-login, ensure_project_open switch, set_pou_code wipe, add_library, list_project_libraries)
* New tools (mirror_export, bump_project_version, release_project_version, read_running_version_online, git_remote_add, git_branch_set_upstream_to, git_push)
* Reliability fixes (launcher refuses double-spawn, shutdown kills orphans, git_* persists via project.save)
- Promote npm install to the top of the install section; source install now follows
- MCP Tools tables now mark each tool as **NEW** or **FIXED** so readers can scan what came from this fork
The 'sp21-plus-migration-notes' branch name was shown in code-style
but not linked. Made it a hyperlink to
https://github.com/phobicdotno/Codesys-MCP-SP21-plus/tree/sp21-plus-migration-notes
so a reader landing on the README can click straight through to the
live source state without first navigating to the repo root.
Also added a sibling sentence pointing at the published npm registry
page (https://www.npmjs.com/package/codesys-mcp-sp21-plus) so the
About blurb now exposes the three canonical landing pages: upstream,
fork repo (+ branch), and npm.
Reader feedback: the install line 'npm install -g codesys-mcp-sp21-plus'
didn't make clear that the npm package IS this fork (rather than some
unrelated package or the upstream luke-harriman/Codesys-MCP).
Changes to the Installation section:
- Lead paragraph now states explicitly that the fork is published as
'codesys-mcp-sp21-plus' by phobic, and that this is this fork (not
upstream, not a Python package).
- 'npm install -g' command moved into a clear 'Install the fork from
npm' subsection with a bullet list explaining what -g does, where
the binary lands on PATH, and which deps come along for the ride.
- Added two verification commands (--version, --detect) so a first-time
user has an obvious next step after install.
- Added an upgrade command ('npm install -g codesys-mcp-sp21-plus@latest')
so the post-install workflow is fully documented.
- 'From source' subsection retitled 'Install the fork from source' for
parallel structure.
Package is live on the npm registry as of 2026-04-26 (version 0.4.0,
maintainer phobic). The bare-name install line that was previously
warned-against now actually works, so it's the leading install path
again. The git-clone workflow is demoted to a 'From source' subsection
for users who want to track sp21-plus-migration-notes directly,
contribute patches, or run a specific commit.
Verified live:
npm view codesys-mcp-sp21-plus version -> 0.4.0
User feedback: don't lead with npm registry concepts when the fork
isn't published. Replaced the three-option list (with the misleading
'not currently published' caveat) with a clean two-step git-clone
workflow:
1. git clone + npm install + npm run build (mandatory)
2. wire into .mcp.json via either:
A) command: 'node', args: ['<path>/dist/bin.js', ...]
(simplest -- no global state)
B) command: 'codesys-mcp-sp21-plus' after running 'npm link'
(only useful if you also want the bin on PATH for ad-hoc use)
Plus a short 'Updating' section that documents what to pull / rebuild
when the upstream branch advances.
Removed entirely:
- 'Option 1: npm install -g github:phobicdotno/...' (still works, but
drowns the simpler path)
- The 'Heads-up on npm install -g codesys-mcp-sp21-plus' caveat
paragraph (the new section doesn't mention the registry name at all,
so the warning has no premise)
Three explicit install paths replacing the misleading 'npm install -g codesys-mcp-sp21-plus' top-line (the fork is not published to npm; bare-name install would fail with 404):
1. npm install -g github:phobicdotno/Codesys-MCP-SP21-plus
-- installs directly from this repo, recommended for end users
2. git clone + npm install + npm run build + npm link
-- recommended for development; lets src/ edits take effect after a
build, and Python script edits hot-reload from dist/scripts/ without
one
3. git clone + npm install + npm run build, then node dist/bin.js
-- avoids touching global node_modules; full .mcp.json snippet shown
Also clarifies up-front that this is a Node/npm package, not a Python
package -- the .py files under src/scripts/ are CODESYS IronPython
templates bundled into the npm distribution, not a pypi project. There
is no pip install. Requirements section now lists the supported CODESYS
versions explicitly (SP19 / SP21 3.5.21.x / SP22 3.5.22.x) and notes
the PDE subscription gate on the git_* tools.
Project rename. The 'SP21+' identifier is more accurate than the
previous 'SP22+' label -- this fork specifically carries the SP21+
migration fixes (the upstream's system.execute_on_primary_thread()
removal and downstream API drift), and is forward-compat with later
SPs. The repo on GitHub has been renamed to
phobicdotno/Codesys-MCP-SP21-plus accordingly.
Substitutions (UTF-8 preserved this time -- prior PowerShell pass
mangled em-dashes via a Win-1252 round-trip):
- package.json: name codesys-mcp-sp22-plus -> codesys-mcp-sp21-plus,
bin entry, repository.url, homepage, description, author trailer
- README.md: title, banner, install + clone snippets, CLI invocations
- ARCHITECTURE.md: comparison-table column header + temp-dir prefix
- tests/TEST_OVERVIEW.md: title
- src/bin.ts: header comment + program().name()
- src/launcher.ts: SESSION_DIR_PREFIX
- src/types.ts: header comment
Compatibility phrase 'Works on SP19, SP21, and SP22+' and the technical
identifier 'SP22 Patch 1 fixes' (which name the actual CODESYS version)
were preserved -- those refer to CODESYS, not the project name.
README additions:
- Quick Start example switched from SP21 Patch 3 to SP22 Patch 1 to
match the more common modern install
- New 'Multiple CODESYS installations' subsection with worked
side-by-side example for SP21 (3.5.21.50) and SP22 (3.5.22.10) --
one named MCP server entry per install, called by name from Claude.
Documents the constraint that --codesys-path/--codesys-profile are
bound at server startup, that --detect lists installs, and that
config edits require a Claude Code restart
- Tool count corrected from 28 -> 37 in the Features bullets
- New Tools sections covering 10 previously-undocumented tools:
Version Anchor + Release Pipeline (bump_project_version,
release_project_version, read_running_version_online), Source
Mirror (mirror_export), and CODESYS Git PDE-gated (git_init,
git_status, git_commit, git_remote_add, git_branch_set_upstream_to,
git_push). Each row carries the actual operational gotchas
discovered during this session (UNC localRepoPath rejection,
push-without-upstream failure, optimizer stripping unreferenced
globals from the online symbol table, etc.)
- list_project_libraries + add_library row text updated to reflect
the post-fix behaviour (compiler-version capture; managed-overload
preference + resolution gate)
Local origin URL updated: phobicdotno/Codesys-MCP -> phobicdotno/Codesys-MCP-SP21-plus.
37/37 tests green.
Project identity rename. Concrete changes:
- package.json
- name: codesys-mcp-persistent -> codesys-mcp-sp22-plus (npm-safe;
'+' is not allowed in npm package names)
- description: prepended 'Codesys-MCP-SP22+' branding + brief
summary of the SP22+ fixes carried in this fork
- bin entry: codesys-mcp-persistent -> codesys-mcp-sp22-plus
- repository.url + homepage: now point at
github.com/phobicdotno/Codesys-MCP-SP22-plus (using -plus
spelling on the URL side; '+' would require URL-encoding)
- author: noted upstream + SP22+ fork attribution
- README.md: title + about-this-fork blurb + clone instructions
- tests/TEST_OVERVIEW.md: title
- ARCHITECTURE.md: comparison-table column header + temp-dir path
- src/bin.ts: file header + program().name()
- src/launcher.ts: SESSION_DIR_PREFIX (with note on backwards compat)
- src/types.ts: file header
Display name everywhere: 'Codesys-MCP-SP22+'.
npm package name: 'codesys-mcp-sp22-plus'.
Out of scope for this commit -- need user action:
- Local directory rename (C:/Users/karstein.kvistad/Codesys-MCP ->
...-SP22+) cannot be done while the persistent MCP is using dist/
from the current path; user can rename after shutting down VS Code.
- GitHub repo rename (phobicdotno/Codesys-MCP -> ...-SP22-plus or
...-SP22+) needs the github.com web UI; the package.json URLs
already point at the post-rename name so a 'git remote set-url
origin <new>' lines up after.
- MCP client config in .mcp.json or similar: the bin name changed
from 'codesys-mcp-persistent' to 'codesys-mcp-sp22-plus' -- update
any 'command' field that referenced the old name. (Local sessions
invoking node dist/bin.js directly are unaffected.)
37/37 tests still green after rename.
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.
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.
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.
Three changes:
1. Fix the bench harness's set_pou_code case. The previous version
passed POU_PATH / DECLARATION_CODE / IMPLEMENTATION_CODE, but the
script template expects POU_FULL_PATH / DECLARATION_CONTENT /
IMPLEMENTATION_CONTENT. Plus the recently-added SET_DECLARATION /
SET_IMPLEMENTATION boolean flags from 35abc8c. With these
corrections, set_pou_code passes for the first time in the bench.
2. Add bench-results-v5.json from a fresh persistent-mode run on
MCPTest2 v1.3.4.0. All 10 cases PASS.
3. Update TEST_OVERVIEW.md with the v5 numbers alongside the v1
historical numbers. v5 is ~2x faster across the board than v1
(likely the cumulative effect of the ScriptManager cache removal +
SP22 Patch 1 IPC improvements). first-cold open_project is slower
than v1 (10.6 s vs 7.7 s) -- one-shot, noise probably; warm
already-open call dropped from 740 ms to 314 ms.
The v5 set_pou_code pass is the third end-to-end signal that the
omitted-decl wipe fix is good (after the e2e regression test and the
live PLC_PRG restoration earlier today).
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.
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.
The dual-SHA tracking commit (146d950) wrote the tag annotation with
`git tag -a -m JSON.stringify(body)`. JSON.stringify escapes newlines
as the literal two-char sequence "\n", and the shell passes those
through unchanged -- so git stored the body as one big line with
literal "\n" chars instead of real LF bytes.
The reader (readTagShas) used a multiline regex anchored on `^` and
`$`, which doesn't match across literal "\n" -- so v1.3.2.0's tag was
written with SHAs in the body but they're invisible to the next
release's read-back.
Two fixes:
- Reader (readTagShas): normalise literal "\n" sequences to real
newlines before applying the regex. Backward-compatible -- handles
the v1.3.2.0 tag transparently and works on properly-formed tags
from v1.3.3.0 onward too.
- Writer (release_project_version step 8): write the body to a
temp file and use `git tag -F <tempfile> --cleanup=verbatim` so
real LF bytes go in. Also adds os import for os.tmpdir().
Verified locally: dist/server.js loads cleanly. End-to-end behaviour
confirmable on the next release_project_version call against any
project.
Implements bidirectional change detection for the release pipeline.
Two SHA-256 fingerprints are now stored in every release tag's annotated
body:
project-sha256: <hash of the .project binary>
mirror-sha256: <hash of the mcp-mirror/ tree>
On the next release_project_version call, these are read back via
git cat-file -p <prior-tag> and compared against the current values
to detect three classes of change that the mirror-only diff missed:
(a) binary changed AND mirror unchanged (working tree, before
mirror_export). Normal "user edited in IDE" path. Classifier
handles this as it always did.
(b) binary unchanged AND mirror changed (working tree, before
mirror_export). User edited .st files in mcp-mirror/ directly
with a text editor. mirror_export is about to overwrite those
edits, so we surface a WARNING in the release log. Future:
a mirror_import tool would push these back into the binary;
until then, mirror is one-way (binary -> mirror).
(c) binary changed AND mirror UNCHANGED after mirror_export. The
.project binary has a non-textual change that mirror_export
doesn't capture: device tree, library refs, task config,
visualizations, OPC UA / symbol config, application composer,
or just a Save() touch (CODESYS embeds timestamps). Classifier
sees no diff but project SHA flipped. Promote 'no-changes' to
a build-level bump so the version still ticks. The Changelog
entry calls out the SHA-fallback evidence so it's visible in
review.
Helper functions added at module scope:
- sha256OfFile(filePath): single-file SHA-256.
- sha256OfDirectory(dirPath): deterministic tree walk, sorts
entries by name, hashes (relative-path, content) pairs separated
by NULs.
- readTagShas(projectDir, tagName): parses project-sha256 /
mirror-sha256 lines out of an annotated tag body. Returns
undefined for either field if missing -- gracefully handles
older tags that don't carry the fingerprints.
The dual-SHA approach was suggested by the user after observing that
a manual edit to MCPTest2.project (made via the IDE) wasn't surfaced
by the mirror-only classifier when the change happened to be in a
non-mirrored region (likely device tree or library refs).
Verification pending: needs a vsc reboot to load the new server.js
into the running MCP process. Once reloaded, the next call against
v1.3.1.0 should:
- Read priorShas from the v1.3.1.0 tag (likely empty since this
is the first release with the new tag format).
- Treat empty priorShas as "no info, can't fall back" and behave
exactly like the pre-fix orchestrator. So nothing breaks.
- Write project-sha256 + mirror-sha256 into the v1.3.2.0+ tag bodies.
- From v1.3.2.0 onward, all three change-detection cases work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Bumped status from "fixed (factory fallback)" to "fixed in c87f3a9
(4-iteration debug saga)" with the v1->v4 iteration table:
* v1 2607063: name= kwarg -- FAIL (SP22 wants foldername)
* v2 e07f281: positional + foldername= -- FAIL (silent None return)
* v3 32e6120: project-level + cache removal -- FAIL (still None=fail)
* v4 c87f3a9: walk children to detect side-effect success -- PASS
- Real root cause documented: SP22's create_folder returns void; the
fix verifies by walking parent.get_children(False).
- Side benefit from v3 (ScriptManager cache dropped) noted as the
hot-reload mechanism that made v4 reachable in one debug session.
- "Lesson for future fork work" callout: probably applies to other
create_* methods too, audit pending.
- Inventory table row for create_folder updated with the c87f3a9
link + a more accurate description of the strategy chain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
- Headline result table at the top: persistent is 15-24x faster than
headless across all CODESYS-roundtripping tools. Numbers are from a
fresh bench-results.json run on MCPTest2.
- Updated mode primer with measured first-call vs warm-call costs:
persistent first launch ~14.6s, subsequent calls ~1.5s; headless
first cold call ~58s, warm calls ~22s.
- Inventory table: replaced "typical" estimates with measured numbers
for the 9 tools the bench covers.
- Deep-dive section: each broken tool now marked "fixed in 2607063"
with concise summary of the fix that landed (instead of "proposed
fix"). Removed the long pre-fix code blocks since they're in the
commit history now.
- Status legend: create_folder, compile_project, get_compile_messages,
connect_to_device, open_project all flipped from broken to fixed
(with caveats: some need PLC or runtime verification).
- Footnote about set_pou_code bench-harness failure (multi-line code
through triple-quoted-string interpolation -- not a tool bug).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
TEST_OVERVIEW.md: complete tool inventory (37 tools), each tagged
working / broken with status notes and per-mode timing characteristics.
Mode primer up top explains the per-call vs first-call cost asymmetry.
Deep-dive on every broken tool with proposed fixes:
- create_folder: parent_object.create_folder() not exposed in SP21+;
fall back to create_object(typeUuid=...) or types.IecFolder.
- compile_project / get_compile_messages: IronPython 2.7 json.dumps
can't serialize System.Int64 (line_number / position fields).
Fix is a _coerce_int helper applied uniformly.
- connect_to_device: SP21+ may expose the login enum as LoginMode
instead of OnlineChangeOption. Extend the candidate sweep over
multiple enum sources, with priority on TryOnlineChange-equivalents.
- open_project (cross-project switch): ensure_project_open has the
"close prior project" branch commented out; uncomment with a
save+close+delay sequence and silent-mode guard.
list_project_libraries is flagged as ✅ working in current SP22
(historical entries in the project memory should be cleared).
bench.mjs: standalone benchmark harness driving HeadlessExecutor and
CodesysLauncher directly (no MCP server in the loop). Copies the source
.project to a temp dir so write tools don't mutate the original. Covers
9 tools (read-only + write-revertible) with configurable iterations,
emits markdown to stdout + JSON to --out.
Run with:
node tests/bench.mjs --modes headless,persistent --iterations 2
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Defensive guard against silent version regressions that the in-script
pi-vs-GVL cross-check can miss when CODESYS's in-memory project tree
is stale (both pi.version and the GVL read come from the same in-memory
source -- a stale tree gives consistent-but-wrong values that defeat
the cross-check).
Two observed regressions on MCPTest2 went undetected by the in-script
check and only failed at git-tag time:
- 2026-04-26 v1.0.4.0 (script saw 1.0.3.0, on-disk was 1.2.0.0)
- 2026-04-26 v1.1.0.0 (script saw 1.0.0.0, on-disk was 1.2.1.0)
This patch adds an orchestrator-side check after bump_project_version
parses its result: re-read the latest v* tag with `git describe`,
compare lexicographically (4-tuple int compare) against the new
version, and abort with a detailed recovery message if the new value
is not strictly greater. The abort happens BEFORE the post-bump
mirror_export, library.md/pou-dump.md regen, README rewrite, Changelog
append, and any git ops -- so no bad state gets published.
The .project binary on disk has still been mutated with the regressed
value at this point (bump_project_version saves at the end), but the
recovery path is well-understood: shutdown + relaunch + reopen to
clear the stale in-memory tree, then retry. Recovery instructions are
included in the error message.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Two real bugs surfaced from the MCPTest2 v1.1.0.0 -> v1.2.0.0 round.
Fix 1: classifier didn't see untracked files (CLASSIFICATION GAP)
Adding FB_Position + FB_Random5s via create_pou + mirror_export
produced new untracked .st files in mcp-mirror/. The classifier
ran `git diff --name-status` which ONLY reports tracked changes;
the new files were invisible until git-added. Result: classifier
counted only 1 'modified' (PLC_PRG, the wiring update) instead
of 1 modified + 2 added, and resolved 'revision' instead of
'minor'. Added a `git ls-files --others --exclude-standard --
mcp-mirror/` pass that pulls untracked files and tags them as
adds (`added (untracked): <path>`). Now create_pou + release
correctly classifies as minor.
Fix 2: orchestrator didn't re-mirror after bump (DESYNC)
Pipeline was: mirror_export -> classify -> bump -> regen md ->
git commit. mirror_export ran BEFORE the bump, so the captured
mcp-mirror/_MCP_PROJECT_VERSION.st reflected the pre-bump GVL
value. Then bump updated the in-memory GVL + saved the .project
binary. Then commit went out with mcp-mirror at the OLD value
while the binary already had the NEW value. Two consequences:
- Next release call sees _MCP_PROJECT_VERSION.st as 'modified'
vs the just-pushed v* tag (because the next mirror_export
pulls the post-bump value, which now differs from the
still-pre-bump mirror in the tag), triggering another bump.
- The runtime-anchor on disk and the runtime-anchor inside the
running PLC binary were silently desynced from the docs.
Surfaced as MCPTest2 v1.1.0.0 (8d79193) shipping with binary
GVL = 1.0.2.0 while docs said 1.1.0.0; resolved by the v1.2.0.0
re-sync (1513e9c).
Added a SECOND mirror_export call right after the bump, before
regenerating library.md / pou-dump.md / Changelog. Soft-fails
with WARNING -- the bump itself already succeeded, post-bump
mirror is the documentation step.
Together these two fixes make release_project_version end-to-end
deterministic: 1 release call -> 1 release commit, no manual finish,
no re-bump on the next call. Verified offline: the path of the new
untracked-detection through ls-files --others, plus the second
mirror_export, give the orchestrator the post-bump state it
previously lacked.
The Python bump script emits 'Project Information.Version: <before>
-> <after>' usually, but when there's no Project Information node
(Standard-template projects -- created via create_project), the
output line becomes 'Project Information.Version: (skipped -- node
missing) -> <after>'. The previous regex used \S+ for the before-
group, which choked on the '(skipped' token (whitespace inside the
parenthetical broke the boundary).
Surfaced on MCPTest2 today during the FB_Position + FB_Random5s
release. release_project_version's bump succeeded (1.0.1.0 -> 1.1.0.0
via the GVL-resume path), but the orchestrator returned 'bump
succeeded but new version could not be parsed' because parseBumpedVersion
returned null and the post-bump pipeline (Changelog + library.md +
README + git ops) never ran.
Fix: non-greedy (.+?) capture for the from-group, treat
parenthesised values or 'none' as null. Also added a Runtime-anchor
fallback regex in case future Python changes alter the metadata
line shape -- the runtime-anchor line carries the same to-version
and is more stable.
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.
Real bug surfaced after a VSC restart left a CODESYS.exe alive that
the new MCP server didn't spawn (state=stopped/error, this.pid=null).
The previous shutdown() early-returned at the top -- the launcher
was 'stopped' so it considered itself done -- which:
1. Left the orphan CODESYS.exe alive (couldn't run the project).
2. Made the refuse-on-duplicate guard (95a884b) block every
subsequent launch_codesys with 'CODESYS already running'.
The launcher was effectively bricked: shutdown said success but
did nothing, launch refused. Hit during today's release-pipeline
test loop.
Fix: when shutdown() is called with state=stopped/error AND this.pid
is null AND findRunningCodesysPids() returns non-empty, taskkill the
orphans before the early-return. Graceful WM_CLOSE first, then 2s
grace, then -F force-kill anything still alive.
Doesn't change the happy-path (launcher tracks its own PID, state
ready -> stopping -> stopped) -- that flow is untouched. The new
code only runs when the launcher would otherwise have been a no-op
on something the OS still has active.
Real bug surfaced on X33 (commit 6c23e38 on karstein.kvistad/x33,
reverted in 3e6f12f): the classifier called git diff --name-status
without any whitespace flags, so a fresh checkout that re-normalised
.st files from LF to CRLF (Windows working copy via Samba share)
showed every file as M. The orchestrator obediently bumped the
project to v1.0.1.0 with no actual code change, committed,
tagged, pushed -- a phantom release.
Fix: add --ignore-cr-at-eol AND -w to the git diff invocation so
the classifier only reports diffs with real content changes.
--ignore-cr-at-eol ignore the carriage-return at the end of line
when comparing lines (handles CRLF<->LF flips)
-w ignore whitespace differences entirely
(defensive; protects against stray blank
lines and indent normalisation that aren't
real changes)
The companion fix is to also add a .gitattributes to each project
that pins the .st files to a stable line-ending in storage so the
phantom diffs don't appear in the first place. That's a per-project
artefact, shipped alongside the project repos (X33 + MCPTest2)
rather than this fork.
Two changes bundled:
1. Adds release_project_version, the one-shot orchestrator that runs
the whole sync from a CODESYS code change to a tagged + pushed
git commit. Sequence:
mirror_export refresh mcp-mirror/
classifier diff vs latest v* tag
-- if no changes short-circuit, no commit
bump_project_version resolved-level bump
Changelog.md append entry with classification
list_project_libraries regen library.md as markdown
get_all_pou_code regen pou-dump.md as markdown
README.md regex-replace v<old> -> v<new>
git add mcp-mirror, .md files, .gitignore,
.project binary
git commit "release v<new> (label)"
git tag v<new> -a + message
git push --follow-tags (configurable via push arg)
This is the standard the project README points at: "ask Claude to
run release_project_version after every confirmed change in
CODESYS." All four sources of truth (Project Information.Version,
_MCP_PROJECT_VERSION.sVersion, Changelog.md, v* git tag) move
together in one call.
Markdown rendering helpers (renderLibraryMd, renderPouDumpMd,
gfmSlug) are extracted to module level so the orchestrator can
call them directly. The list_project_libraries tool's response
formatting still uses inline rendering since it returns plain
text; markdown is for the on-disk artefact only.
2. Changelog entry timestamp now includes HH:MM in local time
(YYYY-MM-DD HH:MM, no seconds, no TZ suffix). User feedback:
date alone was too coarse to distinguish multiple bumps in the
same day. Format chosen for compact heading + sort-friendly +
no timezone-conversion friction. appendChangelogEntry handles
both auto-mode (called from bump_project_version --auto) and
manual-mode (called from release_project_version directly).
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.
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.
After every successful bump (auto or manual), append an entry to
<projectDir>/Changelog.md describing the change. Newest entries at
the top under a one-time intro header. Each entry carries:
## v<X.Y.Z.W> -- YYYY-MM-DD (<label>) [(from `<prev>`)]
- <evidence bullet 1>
- <evidence bullet 2>
...
Where:
<label> is one of:
seed -- first-run, no prior version
auto: <level> -- classifier resolved to <level>
manual: <level> -- explicit level passed
<evidence> is the same classification list shown in the tool
response (D/R/A/M file paths from the mcp-mirror/ diff against
the latest v* tag); for manual bumps it's empty + a "(no
classification evidence -- manual bump)" placeholder line.
Soft-fail: any I/O error during Changelog write logs a warning but
does not fail the bump itself -- the Project Information.Version
update has already succeeded by the time this runs, and the
Changelog is documentation, not state-of-truth.
Format choices:
- File path: <projectDir>/Changelog.md (alongside README.md /
library.md / pou-dump.md, mirroring the existing convention).
- Heading style: H2 ## per version, H1 # for the file title only.
Matches GitLab GFM auto-anchor expectations.
- Date: ISO YYYY-MM-DD in UTC, so collaborators in different
timezones see the same date for the same bump.
- Insertion: before the first existing ## v<...> heading, after
the intro. So a chronological reader sees newest first.
- Versions explicitly cross-referenced to the runtime anchor
(_MCP_PROJECT_VERSION.sVersion) in the intro so the reader knows
a Changelog entry == a value the running PLC will report back
via read_running_version_online.
Now rounds out the version-tracking convention end-to-end:
Project Information.Version (offline metadata)
_MCP_PROJECT_VERSION.sVersion (runtime anchor in IEC code)
Changelog.md (human-readable history)
v* git tag (machine-readable history /
classifier baseline for
subsequent auto-bumps)
All four move together on every bump.
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.
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.
Previously, level=auto with no diff against the latest v* tag still
called the Python bump with level='build', incrementing 1.0.0.0 to
1.0.0.1 even though nothing in mcp-mirror/ had changed since the
baseline. That's wrong -- 'no changes' should mean 'no bump'.
Refactored the classifier to return a tagged ClassifyResult:
kind: 'no-changes' -> short-circuit, no Python call, return a
'no version change' message with evidence.
kind: 'first-run' -> no v* tag yet (or not a git repo); call
Python with level='build' which triggers
the seed-at-1.0.0.0 path when Version is
unset.
kind: 'bump' -> resolved level + evidence; call Python.
Test against X33 right now: latest tag is v1.0.0.0, mcp-mirror/ has
no changes against that tag, so auto would correctly return 'no
version change' without bumping.
Replaces the manual 'pick the right level' workflow with automatic
classification driven by git-diff over the project's mcp-mirror/
folder. Today's standard is the only one we care about (no project
has version-tracking hooked up before this fork shipped it), so the
classifier looks at exactly the artefacts the MCP itself writes.
Classifier rules (file-granularity in v1):
any D (delete) or R (rename) -> major (public symbol gone)
any A (add) -> minor (new public symbol)
any M (modify) -> revision (internal change)
no changes / no v* tag -> build (also triggers the
Python-side seed-at-
1.0.0.0 first-run path
when Version is unset)
When level=auto:
1. Resolve the project's parent directory.
2. Verify it's a git repo (fall back to 'build' if not).
3. Find the latest v* tag via `git describe --tags --abbrev=0
--match "v*"`.
4. `git diff --name-status -M50% <tag> -- mcp-mirror/` and tally
D/R/A/M counts.
5. Resolve to one of major/minor/revision/build per the rules above.
6. Pass the resolved level to the existing Python bump script.
The classification evidence (each D/R/A/M file path) is included in
the tool response so the user can audit the decision -- 'why did this
bump revision and not minor?' has a one-line answer.
Future iterations: split each modified .st file at its
`(* === IMPLEMENTATION === *)` separator and distinguish decl-only
changes (minor / major) from impl-only changes (revision); also wrap
this into a release_project_version orchestrator that re-runs
mirror_export, regenerates library.md, updates the README header,
and tags + pushes the resulting commit. Out of scope for this commit.
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).