Both tools silently DISCARDED declarationCode/implementationCode (zod strips
unknown keys): the object was created EMPTY, compiled clean, and did nothing.
Cost a full download/debug cycle on the fp-j1939 bench session 2026-07-17.
Now the params are real: applied after creation via ScriptTextualObject
textual_declaration/textual_implementation.replace() (same proven API as
set_pou_code), failing LOUDLY if provided code cannot be applied.
Also extend findReservedIecIdentifiers beyond time-suffix letters to the full
IEC 61131-3 ST reserved-keyword set, checked case-insensitively ('by'/'BY' are
both the FOR-loop step keyword -- 'by : BYTE;' failed on the bench 2026-07-16).
Sources: IEC 61131-3 (3rd ed.) keyword tables; CODESYS export-format keywords
(content.helpme-codesys.com/en/CODESYS%20Development%20System/_cds_keywords.html).
Standard-function names (MIN/MAX/ABS/...) deliberately excluded: not confirmed
compiler-rejected, and a false positive blocks legitimate code. Scanner now also
checks every name in comma-separated declaration lists and AT %address forms.
Guard wired into create_pou + create_method declarationCode, same as set_pou_code.
Tests: tests/unit/reserved-iec-identifiers.test.ts (8 cases); full suite 214 green.
set_gateway_and_address raises 'Invalid address format' for 'ip[:port]'
strings. Detect IP form and route it to ScriptDeviceObject
.set_gateway_and_ip_address (helpme-codesys ScriptDeviceObject, since
3.5.8.0), which binds the block driver directly by IP -- needed for
SSH-tunnelled PLCs (e.g. 127.0.0.1:11740 -> lab PLC via jump host).
Live-verified against WAGO PFC200 10.0.0.202 through codesys1-prox.
28 of the 46 new tools verified against CODESYS V3.5 SP21 Patch 5; 7 SP21
stub-vs-runtime drift bugs found live and fixed. Remaining live checks:
import_text_list_file and the 12 online tools (need the WAGO PFC200).
102 tools total. SP21 ScriptEngine functional coverage complete per plan
(SVN/Composer/Cloud deliberately out of scope; plug_module and trace
tools deferred).
Persistent mode now uses a LazyPersistentExecutor everywhere the launcher
is not running: --no-auto-launch before first use, after shutdown_codesys,
and after a launch conflict. The first tool call launches the VISIBLE IDE
and delegates; nothing silently spawns --noUI processes anymore.
Why: headless spawns pop modal dialogs nobody can see (tool calls just
abort), hold .project locks (stale .~u files), and leave orphaned
CODESYS.exe processes that then block the next launch. Diagnosed live on
2026-06-11 while deploying TestN2k_v2_Fable: open_project/download ran
headless via the --no-auto-launch fallback and burned ~30 min on invisible
dialogs, zombie PIDs and lock-file cleanup.
Headless execution now requires explicit opt-in: --mode headless or
--fallback-headless.
Also lands the task-configuration tools from the 2026-06-09 session that
were complete but uncommitted: list_tasks, add_pou_to_task,
remove_pou_from_task (+ their IronPython scripts).
CODESYS opens/saves projects from UNC paths (\server\share\...) only
unreliably -- it tends to fail late and opaquely. Add src/path-guard.ts
(isUncPath + uncPathError) and gate open_project, create_project,
save_project, and launch_codesys_with_project on it: each now returns
isError early telling the user to map a drive (net use Z: \server\share)
or copy the project to a local drive. Mapped drive letters and local
drives (incl. \?\C:\) are not treated as UNC, so existing workflows are
unaffected.
Also bumps version to 0.10.1 (first published release of the 0.10.x line;
includes the prior unreleased phobiCS-tui removal and add_device tool).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wraps ScriptObject.update(device_id) per the CODESYS Forge snippet so
a project can be retargeted between device families without destroying
the Application/POU/library subtree. Used for the WAGO PFC200 ->
CODESYS Control for Raspberry Pi MC SL workflow documented in the new
deploying-plc-project-to-rpi skill in mr-engineering-skill.
Intentionally NOT a remove+add fallback: if update() raises (cross-
family swap the IDE can't do in-place), surface the error rather than
silently destroying the subtree the user wants to keep.
Inputs:
projectFilePath -- target .project
targetDeviceName -- substring of repo display name
devicePath -- optional: device under project root; auto-picks
the first routed device, then any top-level
device, when omitted
targetVersion -- optional: exact version (else latest)
Detached spawn of an arbitrary CODESYS.exe with a .project as CLI arg
and optional --Profile= override. Useful when you want an SP22-saved
project opened in an SP21 IDE for SIM/inspection work, or when this
MCP is bound to install A but you want install B to handle the open
without registering a second server.
The launched IDE is not managed by this MCP: no IPC, no watcher, no
shutdown_codesys. Validates the exe + project paths up front and
returns the new PID.
Two new MCP tools to drive previously IDE-only workflows:
- add_device_user: add (or update password of) a user in the PLC
runtime's live User Management via
ScriptOnlineDevice.create_live_user_management() + add_user(). Required
for OPC UA authentication on CODESYS Control SP16+, where the OPC UA
server reads its UserIdentityToken policies from the runtime UserMgr.
Handles add-vs-update by calling live_um.upload() to sync local cache,
and falls back to set_user_password if add_user reports "already
existing" (the upload() race).
- grant_object_access: set Access Control permissions on a project
object for a user group. Maps to the IDE's Properties -> Access
Control matrix. Wraps ScriptUserManagement.get_object_permission() +
ScriptObjectPermission.set_permission_state(). Common use case: grant
Everyone View+Modify on the Symbol Configuration so the downloaded
OPC UA server can advertise UserIdentityTokens for clients.
Both tools follow the existing pattern: JSON markers in the script,
extractMarkerJson() to surface the output, ensure_project_open helper.
Also bumps grant_object_access timeout to 120s after observing that
first-touch project ACL operations can be slow.
ensure_project_open opened projects with VersionUpdateFlags.NoUpdates |
SilentMode. SilentMode loads the project into the scriptengine context
only -- the IDE's project explorer stays empty, so the user can't see
which project the MCP is operating on (or whether it actually loaded).
download_to_device, set_pou_code, etc. all still worked, but the IDE
window looked blank, which was confusing.
Drop SilentMode. NoUpdates stays (don't auto-update libraries on open;
that would silently change project content). With this change, opening
a project via the MCP behaves like a normal File -> Open: the tree
populates, the user can navigate it, and we can still drive
modifications from script.
When the MCP server runs under Claude Code (stdio JSON-RPC), there is
no TTY attached -- the parent process owns stdin/stdout for the protocol
channel. phobiCS-tui spawned with stdio=inherit then has nothing to
render to and exits immediately, which gateOpForTool was interpreting
as "user rejected the change" -- silently blocking every modifying
operation (download_to_device, set_pou_code, create_*, etc.) with no
visible cause.
Detect non-TTY at gate entry. When phobiCS-tui isn't viable, log a
clear warning to stderr (visible in MCP server logs) and let the
operation proceed instead of falsely rejecting it. Applies to both
the generic gateOpForTool path and the set_pou_code runApproveGate
path.
User can pass --no-approve-edits to silence the warning when they
explicitly don't want the gate. The right long-term fix is to route
approval through the MCP elicitation protocol so the user gets the
prompt in their chat UI; that's a separate change.
End-to-end download via MCP was failing for three reasons; all three
fixed here so the agent can drive the whole flow without manual IDE
clicks.
1. verify_device_reachable did a live gateway.perform_network_scan()
on every call. That freezes the CODESYS UI thread for the full
scan duration, so the user couldn't interact with the IDE during
pre-flight (and a 60s tool timeout was easy to hit). Now reads
gateway.get_cached_network_scan_result() first (instant, no UI
block), only falls back to a live scan when no cache exists.
Adds scan_source ("cache" | "live") to the JSON so callers know
which path was taken.
2. rebind_device_to_scan short-circuited with "already-bound" when
the resolved address matched the cached one. But the IDE's
Select-Device + OK flow re-applies set_gateway_and_address even
when the address didn't change -- that's what refreshes the
device's scanned_* properties and re-establishes a session.
Skipping it left the binding stale even after a successful
rebind call. Always apply now.
3. ensure_online_connection created an online_application but never
opened a device session. login() then waited on (or popped a
dialog for) a session nobody had opened -- which is what the
user was doing manually as "right-click device -> Connect".
Adds _ensure_device_connected() helper that locates the project's
PLC device, creates a ScriptOnlineDevice, and calls connect() if
not already connected. Idempotent; best-effort (logs and falls
through on failure so login() can still try its own path).
scan_network_devices, verify_device_reachable, and
rebind_device_to_scan_result all emit JSON between marker pairs in their
script stdout. The tool handlers wrapped that output with
formatToolResponse which returns only the success line on success --
hiding the actual data so the agent had nothing to act on.
Adds extractMarkerJson() helper that pulls the block between the start
and end markers and pretty-prints it, then returns the JSON in the tool
response. Now the agent sees the full scan results / reachability
candidates / rebind outcome.
The scan_network_devices, verify_device_reachable, and
rebind_device_to_scan templates used plain "..." strings for
PROJECT_FILE_PATH. With a UNC path like \\files\... the rendered Python
source had "\\files\..." which Python's source parser collapses to
"\files\..." (one backslash). ensure_project_open then saw the mangled
path as different from the (correctly-opened) primary, CLOSED the
primary project, and failed to reopen the corrupted path.
Other scripts use r"..." to bypass source-level escaping. Applying the
same to the three new ones.
Also bumps to 0.9.5.
* fix: disable headless fallback by default
Persistent mode silently fell back to running CODESYS with --noUI
whenever the launch refused (e.g. another GUI instance was already
running). That left the user staring at no window while the MCP
spawned hidden processes per command. Flip the default of
--fallback-headless to false so the MCP errors out loudly instead;
opt in explicitly if silent --noUI is actually wanted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: clear stale lastError on launch retry and successful ready
get_codesys_status kept reporting historical "Last Error: Refusing
to launch..." messages forever because lastError was set on failure
but never reset. Reset to null at the top of launch() so a retry
starts clean, and again when the watcher signals ready so a
successful re-launch wipes prior errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* release: v0.9.2 -- no headless fallback by default + clear stale lastError
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Karstein Phobic Nyvold Kvistad <karstein.kvistad@maritimerobotics.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The TUI subpackage imports 'diff' at runtime (computeHunks in
src/tui/shared/diff.ts), so a global install via 'npm install -g'
was breaking with:
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'diff'
imported from .../dist/tui/shared/diff.js
devDependencies are not installed by 'npm install -g <pkg>', only
'dependencies' are. Move 'diff' to the right section.
This bug shipped silently in 0.5.0 onwards because the source-build
flow during dev install everything, masking it. First reproduces
when a user does a fresh global install -- which is the only flow
the published package should support.
Bumps to 0.9.1; tag + republish required to fix the live install.
Bumps codesys-mcp-sp21-plus from 0.8.0 to 0.9.0.
Headline changes since v0.8.0:
Live values:
- Pump now descends one level into user-defined types. A var
whose declared type resolves to another mirror .st file (a
POU/FB/STRUCT/DUT) gets each of its members read as
<var>.<member>. Caller-supplied resolveTypeMirror dep so the
pump stays decoupled from the mirror layout.
- --live-values-interval <ms> CLI flag (default 500, clamped
[100, 60000]).
add_library:
- SP22 librarymanager attribute name fix. The SP22 stub
documents 'library_manager' (underscore) but the actual
injected attribute is 'librarymanager' (one word). Now
probes both with SP-version-aware preference + adds an
SP22-specific find_library dispatcher that walks
lm.repositories when the documented signature is rejected.
Bumps codesys-mcp-sp21-plus from 0.7.1 to 0.8.0 and phobiCS-tui's
--version output from v0.2.0 to v0.3.0.
Headline changes since v0.7.1:
TUI:
- Viewer overlays each declared variable's live runtime value
inline next to its declaration when the runtime is online.
- Viewer joins multi-line (* ... *) comments across line
boundaries (lines fully inside a block no longer get
false-highlighted keywords).
MCP server:
- new --live-values CLI flag starts a 500 ms pump that reads
runtime values for the variables of the user's current POU
selection and writes them to tui-live-values.json next to the
existing tui-state.json.
- --approve-edits now also gates 4 runtime tools:
write_variable, download_to_device, start_stop_application,
restart_runtime_ssh (in addition to the 9 modifying project
tools landed in v0.7.0).
No tag created here; npm publish has to be run from your terminal
because the npm 2FA passkey can't be driven through the bash tool.
Two bug fixes since v0.7.0:
e948922 fix(add_library): wrapper message reflects dedup vs add branch
d01f6ed fix(test): retry on PermissionError in mock_watcher (Win race)
Bumps codesys-mcp-sp21-plus to 0.7.0 (main was at 0.6.4 from
intermediate releases) and phobiCS-tui's --version output to v0.2.0.
Headline changes since v0.6.4:
TUI:
- browser keybinds: / (filter), o (open in editor), d (cross-
device diff), r (rescan), ? (help overlay)
- approve mode: v toggles unified <-> side-by-side diff
- viewer: ST syntax highlighting (keywords/types/comments/strings)
- statusbar: stale-mirror indicator + small-terminal resize warn
MCP server:
- --approve-edits now gates ALL 9 modifying tools, not just
set_pou_code: create_pou, create_property, create_method,
create_dut, create_gvl, create_folder, delete_object,
rename_object, add_library
No tag created here; npm publish has to be run from your terminal
because the npm 2FA passkey can't be driven through the bash tool.
WHY: an unlicensed CODESYS Control runtime drops out of demo mode every 2
hours. systemctl is-active reports "active" even after the binary has
died, so a TCP probe on the runtime port (default 11740) is the only
honest liveness signal. The new tool gives MCP a one-call path to bring
the runtime back without dropping into a terminal.
Implementation choices:
- ssh2 (npm) instead of spawning ssh/sshpass: sshpass is not on the
default Windows path, and the target Pi's sshd 10.x rejects pubkey
signatures from this client environment in practice. ssh2 handles
password auth + remote stdin + exit-code capture cross-platform.
- sudo -S with the password fed on remote stdin -- avoids a NOPASSWD
sudoers entry on the PLC.
- After issuing the restart, polls 'ss -tln | grep :<port>' once per
second until the listen port is up or livenessWaitSeconds expires.
This is what catches a half-dead runtime that systemctl reports as
fine.
Defaults match the only Pi we currently target (codesys-pi.local /
karstein / codesys123 / codesyscontrol / port 11740) but every field
is overridable.
Smoke-tested against codesys-pi.local: restart exit 0, port back up
after ~3s.
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>