0
0
Fork 0
Codesys-MCP-SP21-plus/ARCHITECTURE.md
Karstein Phobic Nyvold Kvistad 405be44a58 rename: Codesys-MCP-SP22+ -> Codesys-MCP-SP21+ + multi-install README + missing 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.
2026-04-26 22:02:46 +02:00

9.4 KiB

Architecture

Problem Statement

The original @codesys/mcp-toolkit spawns a new headless CODESYS process (--noUI) for every MCP tool call. This has two limitations:

  1. No UI visibility — the user cannot see what the AI is doing to their project
  2. Project locking — if the user opens CODESYS manually, the project file is locked and MCP tools fail

The desired workflow: a single CODESYS instance with its UI open, where MCP tool commands execute in the same process and changes appear in real-time.

Architecture Overview

+-------------------------------------+
|      MCP Client (Claude Code)       |
+------------------+------------------+
                   | MCP Protocol (stdio)
+------------------v------------------+
|    Node.js MCP Server               |
|                                     |
|  bin.ts  -> CLI entry point         |
|  server.ts -> MCP tools/resources   |
|  launcher.ts -> Process management  |
|  ipc.ts -> File-based IPC           |
|  headless.ts -> Fallback mode       |
|  script-manager.ts -> Templates     |
+------------------+------------------+
                   | File-based IPC (persistent)
                   | OR spawn-per-command (headless)
+------------------v------------------+
|    CODESYS.exe                      |
|  watcher.py running inside via      |
|  --runscript (persistent mode)      |
+-------------------------------------+

IPC Protocol

Directory Layout

Each session creates a unique directory under os.tmpdir():

%TEMP%/codesys-mcp-sp21-plus/<sessionId>/
  commands/           Node.js writes here
    <requestId>.py              Script to execute
    <requestId>.command.json    Command trigger file
  results/            Watcher writes here
    <requestId>.result.json     Execution result
  watcher.py          Interpolated watcher script
  ready.signal        Written by watcher on startup
  terminate.signal    Written by Node.js for shutdown

Command File Format

<requestId>.command.json:

{
  "requestId": "uuid-v4",
  "scriptPath": "/path/to/commands/<requestId>.py",
  "timestamp": 1700000000000
}

Result File Format

<requestId>.result.json:

{
  "requestId": "uuid-v4",
  "success": true,
  "output": "captured stdout from script execution",
  "error": "",
  "timestamp": 1700000000.123
}

Write Ordering (Atomicity)

All files use atomic writes: write to .tmp, fsync, then rename.

Command submission order:

  1. Write <requestId>.py (script content) -> fsync -> rename
  2. Write <requestId>.command.json.tmp -> fsync -> rename to .command.json

The watcher triggers on .command.json appearance. Since the .py file is written and renamed first, it is guaranteed to exist when the watcher reads the command.

Progressive Polling

Node.js polls for result files with exponential backoff:

  • Initial interval: 100ms
  • Doubles each poll: 100, 200, 400, 800, 1000ms
  • Capped at 1000ms
  • Default timeout: 60s (120s for compile)

Watcher Script

The watcher (src/scripts/watcher.py) runs inside CODESYS via --runscript and provides the bridge between Node.js IPC and the CODESYS scripting API.

Polling Loop

while True:
    if check_terminate():
        break
    command_files = scan_commands_dir()
    if command_files:
        process_command(command_files[0])  # one per iteration
    time.sleep(0.05)  # 50ms yield to UI thread

The 50ms sleep interval balances responsiveness (commands processed within ~50ms) against UI thread availability (CODESYS UI stays responsive).

Script Execution via exec()

Each command script is executed with exec(script_code, exec_globals) where exec_globals is a fresh dictionary:

exec_globals = {
    '__builtins__': __builtins__,
    'sys': sys,
    'os': os,
    'time': time,
    'traceback': traceback,
    'shutil': __import__('shutil'),
}

This provides:

  • Namespace isolation — variables from script A are not visible to script B
  • CODESYS API accessscriptengine is available via import scriptengine because the watcher runs within the CODESYS scripting context (it's already in sys.modules)
  • Standard library access — common modules pre-loaded in globals

SystemExit Handling

CODESYS scripts use sys.exit(0) for success and sys.exit(1) for failure. The watcher catches SystemExit to prevent CODESYS from closing:

Exit code Mapping
None or 0 Success
Non-zero int Failure
String Failure (string is the error message)

Output markers (SCRIPT_SUCCESS / SCRIPT_ERROR) take priority over exit codes when both are present.

Output Capture

The OutputCapture class redirects sys.stdout and sys.stderr during script execution:

class OutputCapture:
    def __init__(self):
        self._buffer = []
    def write(self, s):
        self._buffer.append(str(s))
    def getvalue(self):
        return ''.join(self._buffer)

Original stdout/stderr are saved and restored in a try/finally block, guaranteeing restoration even on unexpected exceptions. This class works across CPython and IronPython (CODESYS uses IronPython).

Script Template System

Python scripts are stored as templates in src/scripts/ with {PLACEHOLDER} tokens. The ScriptManager handles:

  1. Loading — reads .py files from disk with caching
  2. Interpolation — replaces {KEY} with escaped values
  3. Escaping — backslashes doubled for Python string embedding (C:\Users -> C:\\Users)
  4. Triple-quote escaping""" in values escaped to \"\"\" for Python triple-quoted strings
  5. Helper prepending — shared functions (ensure_project_open, find_object_by_path) prepended before the main script

Helper Scripts

Two helper scripts are prepended to most tool scripts:

  • ensure_project_open.py — opens a project file if not already open, with retry logic (3 attempts, 2s delay)
  • find_object_by_path.py — navigates the CODESYS project tree to find objects by path (e.g., Application/MyPOU)

Lifecycle Management

Launch Sequence

  1. Validate CODESYS executable exists
  2. Generate session UUID
  3. Create IPC directory with commands/ and results/ subdirectories
  4. Load watcher.py template, interpolate {IPC_BASE_DIR}
  5. Write interpolated watcher to session directory
  6. Spawn: CODESYS.exe --profile="..." --runscript="watcher.py" (detached, UI visible)
  7. process.unref() so Node.js doesn't wait for CODESYS
  8. Poll for ready.signal (max 60s, every 500ms)
  9. Start health monitor (5s interval PID check)

Shutdown Sequence

  1. Write terminate.signal
  2. Wait up to 5s for process exit (poll every 500ms)
  3. If still alive: SIGTERM, wait 2s, then SIGKILL
  4. Clean up IPC directory

Health Monitoring

A setInterval runs every 5 seconds checking if the CODESYS process is still alive (process.kill(pid, 0)). On process death:

  • State transitions to error
  • lastError is set with a descriptive message
  • Registered onStateChange callbacks are invoked
  • Monitor stops itself

Concurrency Model

Async Mutex

The IpcClient uses an async mutex to serialize commands. Only one command can be in-flight at a time. This prevents:

  • Race conditions in the CODESYS scripting API (not thread-safe)
  • File system conflicts in the IPC directory
  • Interleaved script output

When multiple tool calls arrive concurrently, they queue and execute sequentially.

Watcher Single-Threaded Processing

The watcher processes one command per polling iteration. If multiple .command.json files exist, they're sorted alphabetically and processed in order.

Headless Fallback

When persistent mode is unavailable, the HeadlessExecutor provides the same ScriptExecutor interface using spawn-per-command:

  1. Write script to temp file
  2. Spawn CODESYS.exe --profile="..." --noUI --runscript="script.py" with windowsHide: true
  3. Capture stdout/stderr
  4. Parse SCRIPT_SUCCESS / SCRIPT_ERROR markers
  5. Return IpcResult

Fallback activates when:

  • --mode headless is specified
  • Persistent launch fails and --fallback-headless is enabled
  • Server starts with --no-auto-launch before launch_codesys is called

Differences from Original Toolkit

Aspect @codesys/mcp-toolkit codesys-mcp-sp21-plus
CODESYS UI Hidden (--noUI) Visible (persistent) or hidden (headless)
Process lifetime New process per command Single long-running process
IPC mechanism Spawn + stdout File-based polling
Project locking Blocks if user opens CODESYS Shares the same instance
Real-time feedback None Changes visible in UI
Startup overhead ~10-30s per command ~10-30s once, then <100ms per command
Management tools None launch_codesys, shutdown_codesys, get_codesys_status

Security Considerations

  • Temp directory — IPC files are created in the user's temp directory with default permissions. No sensitive data (credentials, keys) is written to IPC files.
  • Script injection — tool parameters are escaped for Python string embedding (backslashes doubled, triple quotes escaped). The exec() context has access to the full CODESYS scripting API, which is the intended design.
  • Localhost only — IPC is file-based with no network exposure. The MCP server communicates via stdio only.
  • Process isolation — CODESYS is spawned as a detached process. The Node.js server can crash and restart without affecting CODESYS (though a new session would be created).