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).
SP21 removed IScriptUser.change_password ('no longer supported'). The
script now probes change_password/set_password/reset_password and, if
none works, saves the user WITH A WARNING instead of failing the whole
creation. Creation is idempotent so a half-failed prior run doesn't
block a retry on the duplicate name.
Same overload drift as export_xml: positional (path, None, folders) put
the path string in the reporter slot ('expected IImportReporter, got
str'). All args now passed by keyword with positional fallback.
Live verification on SP21: users.create raised "permission 'Modify' not
granted to user '(nobody)'". The scripts now log in as 'Owner' with empty
password (CODESYS default) when nobody is logged on; new optional
adminUser/adminPassword args override for protected projects.
Live verification: positional (objects, None, path, ...) bound 'objects'
into the reporter slot on SP21's overload, silently switching export_xml
into export-to-string mode -- tool reported success with no file written.
All args now passed by keyword; export_plcopen_xml and export_native
raise if the destination file does not exist after the call.
Live verification against CODESYS V3.5 SP21 Patch 5: calling it raised
'bool is not callable'. Handle both the property (SP21) and callable
(stub-documented) shapes.
1. find_object_by_path: final name verification used the original
full_path instead of the dot->slash normalized segments, so
dot-separated paths ('Application.MyPOU') traversed correctly but
failed the final check and returned None.
2. User-arbitrary values (plcPath/plcDirectory, passwords, comments,
project-info fields, device parameter name/value, task event,
device credentials) were interpolated into r"..."/r"""...""" Python
literals unescaped -- a quote or triple-quote in the value broke the
generated script (or injected code). Templates now take pre-escaped
literals via pyStringLiteral().
Reviewed-range: dead49a..e9aa714. Third reviewer finding (task.priority
must be int) was rejected: SP21 ScriptTaskConfigObject.pyi types the
priority/interval/interval_unit setters as str.
- add_library.py line 49 had UTF-8 box-drawing dashes in a comment with no
coding declaration -- latent IronPython 2.7 source-encoding risk.
- build now removes dist/scripts before copying, so deleted/renamed
templates (compile_project.py.bak, probe_app_error_state.py,
set_library_namespace.py) no longer ship in the npm tarball.
- script-manager test now asserts EVERY template is ASCII-only instead of
per-phase lists.
102 tools total. SP21 ScriptEngine functional coverage complete per plan
(SVN/Composer/Cloud deliberately out of scope; plug_module and trace
tools deferred).
String.replace with a string replacement interprets $$/$& as regex
replacement patterns, corrupting IEC string literals like '$R$N' passed
through tool params (set_pou_code code bodies, write_variable values).
Use a function replacement so values pass through verbatim.
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).
The server is a standard stdio MCP server and works with any MCP client, not
just Claude Code. Added a "Use with OpenAI Codex CLI" section covering the
~/.codex/config.toml location, the [mcp_servers.<name>] TOML table (the
equivalent of the --print-config JSON block), env-based credential injection,
tool_timeout_sec for slow CODESYS launches, and the codex mcp add shortcut.
Linked it from Quick Start step 3.
registerIdeBridgeTools() spawned CodesysMCPBridge.exe but the SIGINT/SIGTERM shutdown handler never closed the client, so every orchestrator exit left the shim running. They piled up across sessions (7 observed live with no IDE open).
- ide-bridge: add findOrphanedBridgePids()/killOrphanedBridges() using dead-parent detection (only reaps shims whose parent process is gone, so live sessions are untouched); harden close() with a taskkill /F fallback; expose .pid getter.
- server: sweep orphaned shims at startup, track the bridge client and close() it on shutdown, and add a process.on('exit') taskkill safety net for non-signal exits (stdin EOF / fatal).
Verified end-to-end against compiled dist: real orphan detected+reaped, live-parent shim left alone. Build clean, 156 tests pass. Bump 0.10.1 -> 0.10.2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Pairs with the add_device MCP tool that landed in e1f47a1 (folded in
during the approve-gate rip). Wraps ScriptDeviceObject.add(name, device_id)
to programmatically attach a child device under a parent device:
'Modbus TCP Server' under an Ethernet adapter, 'Ethernet' under the top-
level PLC, etc.
Behaviour:
- Required inputs: PROJECT_FILE_PATH, PARENT_PATH ('/'-separated, e.g.
'MainPLC' or 'MainPLC/Ethernet1'), DEVICE_NAME, TARGET_NAME (substring
of device repository display name).
- Optional: TARGET_VERSION (exact, e.g. '4.5.0.0'). Omit -> highest-version
match wins, mirroring update_device_type's resolver.
- Suppresses CODESYS prompt dialogs via PromptHandling.NONE (with int=0
fallback for SP21).
- Idempotent: if a child with DEVICE_NAME already exists under the parent,
no-ops with SCRIPT_SUCCESS rather than creating a duplicate or erroring.
- Refuses when:
- parentPath isn't found in the project,
- the object at parentPath isn't a device (only attaches under device-
typed ScriptObjects),
- TARGET_NAME (+ optional version) has no match in the device repo --
error suggests inspecting Tools > Device Repository.
- On ScriptDeviceObject.add() failure prints the full traceback plus the
two common causes (parent doesn't allow this child type; device
descriptor needs an uninstalled library).
After add, saves the project. Save failure is a WARN, not a hard error.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Knock-on cleanup from the phobiCS-tui nuke in 4b82b7c. With the TUI gone
the gate had no UI to render -- it just printed a stderr warning and
auto-approved on every call. Ripping it out kills ~470 lines of dead code
and a redundant CLI flag.
Removed:
- src/approve-gate.ts (whole file; runApproveGate + gateOpForTool + helpers)
- tests/unit/approve-gate.test.ts
- bin.ts: --approve-edits CLI option, approveEdits config wiring, startup log
- types.ts: ServerConfig.approveEdits field + its doc comment
- server.ts: import of runApproveGate/gateOpForTool, the `Approve edits:` log,
16 `gateOpForTool({...}); if (blocked) return blocked;` blocks across
every modifying MCP tool handler, and the lone `runApproveGate({...})`
block in set_pou_code.
Folded in (Karstein's prior WIP, gate-free now per his explicit choice
"Single rip commit, I edit your WIP too"):
- New `add_device` MCP tool in server.ts that wraps ScriptDeviceObject.add
for attaching child devices (Modbus TCP Server under Ethernet, Ethernet
under PLC, etc.). The supporting `src/scripts/add_device.py` remains
UNTRACKED in Karstein's working tree -- still his to commit separately.
Without that script the tool will fail at runtime; with it, fine.
Verify:
- `npx tsc --noEmit` clean
- `npm run build` clean
- `npx vitest --run tests/unit/` -> 14 files, 119 tests, all pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The phobiCS-tui CLI/UI is retired in favour of driving the codesys-mcp-sp21-plus
MCP tools directly from Claude Code (see https://docs.anthropic.com/claude-code).
The MCP server itself is unchanged; only the TUI front-end goes.
Removed:
- src/tui/ (browser + approve + shared + entry; 14 files)
- dist/tui/ (compiled output)
- tests/tui/ (14 .test.* files + the mini-mirror fixture tree)
- tsconfig.tui.json
- 3 superpowers plans/specs docs (2026-04-28 phobics-tui v0.1-v0.2, 2026-04-29 v0.3-live-values, 2026-04-28 tui-design)
- package.json: phobiCS-tui bin entry, build:tui script, TUI compile step in build, TUI typecheck step
- package.json: dependencies ink + react + diff (TUI-only); devDependencies @types/diff + @types/react + ink-testing-library
- README.md: ## phobiCS-tui section + ### Inline live values subsection
Git history side:
- Worktree .worktrees/phobics-tui removed (was on feature/phobics-tui-followup @ 7e427e9)
- Local + origin branches deleted:
- feature/phobics-tui (was 08ee361, 0 unmerged vs origin/main)
- feature/phobics-tui-followup (was 7e427e9, 0 unmerged vs origin/main)
- feature/phobics-tui-v0.3-live-values (was 9f4dc48, 0 unmerged vs origin/main)
- All three branches were merged into main, so deleting refs loses no history --
the commits remain reachable through main.
Knock-on (deliberately deferred):
- src/approve-gate.ts and the --approve-edits flag in src/bin.ts / src/server.ts
still exist. With the TUI gone, the gate auto-approves at every prompt and
prints a `[approve-gate] No TTY available -- phobiCS-tui cannot render` warning
to stderr. The infrastructure also still has callers in the uncommitted
add_device work, so a clean rip-out is left for a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Once launch() refused due to a foreign CODESYS, the error string was
cached in lastError and getStatus() returned that frozen snapshot
forever. Closing the foreign CODESYS did not update the status -- only
an MCP restart cleared it. From the user's POV: "every time CODESYS
HAS been open, you get a problem".
Fix: getStatus() now calls revalidateLaunchRefusal() first. If the
launcher is parked in 'error' state with a "Refusing to launch:"
prefix and findConflictingInstances() now returns empty, we transition
back to 'stopped' and clear lastError so the next status call / launch
attempt sees a fresh state. Other 'error' states (process died,
watcher timeout, exe not found) are not auto-cleared -- only the
launch-refusal cache, since that's the one that goes stale on its own
when the user closes the foreign window.
Bump 0.9.13 -> 0.9.14.
Tests: 2 new launcher unit tests pin the auto-clear behaviour and
verify unrelated 'error' states are NOT auto-cleared. 22/22 launcher
tests pass.
Co-Authored-By: Claude Opus 4.7 (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.
Adds an opt-in --ide-bridge auto|on|off flag. When the CODESYS install
ships the bridge shim (CodesysMCPBridge.exe alongside CODESYS.exe in
SP22 Patch 1 onward) and the in-IDE plugin is loaded, the sp21-plus
server spawns the shim as a child process, fetches its tools/list, and
re-registers each bridge tool under an 'ide_' prefix that forwards
JSON-RPC verbatim. Gives us the bridge's authoring tools (which mutate
the live project graph and pop the affected POU into the editor view
immediately) while keeping our 48 watcher tools as the home of online,
runtime, SSH, symbol-config, and release-pipeline work.
Backward-compatible by design: SP19/SP21 installs don't ship the
bridge, so defaultExePath() returns null and registration silently
skips under mode=auto. Under mode=on the server fails loudly.
The bridge's stdout speaks newline-delimited MCP JSON-RPC, exactly
like our own stdio transport, so the client is a small subprocess
wrapper plus a minimal JSON-Schema -> Zod-shape converter (handles
string/number/boolean/array/object/enum + optionality, falls back to
z.unknown() for anything else). Verified end-to-end against an open
MCPTest2 project on SP22.10:
Bridge initialize OK (protocolVersion=2024-11-05)
IDE bridge attached. Registering 19 passthrough tool(s) with 'ide_' prefix.
ide_get_active_app_as_path -> 'CodesysRpi.Plc Logic.Application'
Tools exposed (prefixed): browse_project_tree, check_for_errors,
create_or_replace_structured_text_object, replace_text_in_structured_text,
get_structured_text_content, get_active_app_as_path,
get_active_path_and_selection, create_folder, remove_object,
add_library, add_program_call_to_task, get_available_libraries_list,
get_libraries_referenced_in_application, get_library_documentation,
get_detailed_library_documentation, get_device_and_io_configuration,
search_in_files_by_regex, search_libraries_for_type, search_path_by_glob.
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.
Stale device addresses (cached in the project's Communication Settings)
are the #1 cause of download_to_device hanging silently: the gateway is
up, but the PLC has moved to a new router address after a reboot/DHCP
event, so login() waits forever on a UI dialog the agent can't see.
Adds three new tools:
- scan_network_devices: drives gateway.perform_network_scan() and emits
the list of discovered targets (device_name, type_name, vendor_name,
address, device_id). Supports useCache=true to read the gateway's
last cached scan result instead.
- verify_device_reachable: scans the gateway and reports whether the
project's cached device address matches a live target. Returns
reachable=true/false plus the candidate list.
- rebind_device_to_scan_result: matches a scan result (by name, by
device_id, by forced address, or single-candidate) and calls
device.set_gateway_and_address() + saves the project so the new
binding persists across CODESYS restarts.
And wires download_to_device to run verify_device_reachable BEFORE
login(). If unreachable, the tool fails fast with a clear hint to call
rebind_device_to_scan_result, instead of dropping into the IDE dialog.
Pass skipReachabilityCheck=true to force-bypass if the gateway/cache
lookup itself is broken on a given SP.
Helper find_target_device.py is shared across the three new scripts.
killExisting=true taskkilled the conflicting CODESYS.exe, but the
immediately-following findConflictingInstances() still saw the PID and
the launcher threw -- even though the process was on its way out.
Windows is async about evicting a PID from the process table after
taskkill returns. Poll for up to 2s, dropping out as soon as none of
the PIDs we just killed are still listed. Only PIDs the user did NOT
ask us to kill end up in the post-kill conflicting list.
Before: if a stray CODESYS.exe of the same install was running when the
MCP server started (typical: orphan from a prior MCP session whose
parent died), launcher.launch() threw and the whole MCP server died on
startup, leaving the user with Failed-to-connect in the IDE and no way
to resolve it without manually killing the orphan first.
Now:
- launcher.launch() tags the conflict error with code
CODESYS_LAUNCH_CONFLICT and a list of conflictingPids.
- server.ts treats that code specially during auto-launch: stay
connected, fall back to HeadlessExecutor, log a warning. The model
(and the user) can now call launch_codesys from chat to resolve.
- launch_codesys gains a killExisting boolean. When true, the launcher
taskkills the same-install PIDs (per the existing kill semantics --
other CODESYS installs are unaffected) and retries the spawn in one
shot.
- Refactors the kill loop into launcher.killConflictingInstances() and
the scan into launcher.findConflictingInstances() so both the
pre-launch guard and the kill path share one source of truth.
Symbol Configuration artifacts (XSD/XML, .app/.crc symbol table) only land
on disk as a side effect of code generation -- not when the config is
edited. So a sequence like create_symbol_config -> set_symbol_access ->
download_to_device would ship the OLD symbol table because no build ran
in between.
After this change, the four symbol-config-modifying tools automatically
run compile_project on success and append the build outcome to the
response: create_symbol_config, set_symbol_config_settings,
set_symbol_access, set_signature_access_bulk.
Refactors the existing compile body out of the compile_project tool into
a runCompile() closure so both tools share the same parse + format
logic, and adds a withAutoCompile() wrapper for the symbol tools.
Two real frictions this session:
1. loginWaitSeconds defaulted to 60. When the user has to fill the
Device User Login dialog, that's done in seconds — but the tool
call would block for the full 60s after they clicked OK, before
reporting back. Worse: download_to_device's tool-side timeout is
(waitSec + 120)s, so 180s if anything else went sideways. New
default: 10s, which still gives the user time to click the dialog
but doesn't punish a fast click.
2. Karstein noted that download was being called silently. Both tool
descriptions now carry an explicit AGENT BEHAVIOUR REQUIRED
directive: announce before calling, warn that the dialog may pop,
user must be ready to click. The directive lives in the tool
description so future agents see it on every call-time schema lookup.
Bumped 0.7.0 → 0.7.1.
* 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>