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.
257 lines
9.4 KiB
Markdown
257 lines
9.4 KiB
Markdown
# 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`:
|
|
```json
|
|
{
|
|
"requestId": "uuid-v4",
|
|
"scriptPath": "/path/to/commands/<requestId>.py",
|
|
"timestamp": 1700000000000
|
|
}
|
|
```
|
|
|
|
### Result File Format
|
|
|
|
`<requestId>.result.json`:
|
|
```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
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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 access** — `scriptengine` 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:
|
|
|
|
```python
|
|
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).
|