From 02bc1d001ad003471b6284bfa79f629460b31e54 Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Tue, 12 May 2026 18:39:21 +0200 Subject: [PATCH] feat(ide-bridge): passthrough for CODESYS-shipped MCP bridge (SP22.10+) 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. --- src/bin.ts | 8 ++ src/ide-bridge.ts | 271 ++++++++++++++++++++++++++++++++++++++++++++++ src/server.ts | 77 +++++++++++++ src/types.ts | 8 ++ 4 files changed, 364 insertions(+) create mode 100644 src/ide-bridge.ts diff --git a/src/bin.ts b/src/bin.ts index cbeac2e..6f54551 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -29,6 +29,12 @@ function clampInterval(raw: string | undefined): number { return Math.max(LIVE_VALUES_INTERVAL_MIN_MS, Math.min(parsed, LIVE_VALUES_INTERVAL_MAX_MS)); } +function normaliseIdeBridge(raw: string | undefined): 'auto' | 'on' | 'off' { + const v = (raw ?? 'auto').toLowerCase(); + if (v === 'on' || v === 'off' || v === 'auto') return v; + return 'auto'; +} + program .name('codesys-mcp-sp21-plus') .description('MCP server for CODESYS with persistent UI instance') @@ -72,6 +78,7 @@ program .option('--ssh-version ', 'SSH to a CODESYS Control Linux PLC and print the running project version (extracted from the boot-application binary), then exit. Bypasses CODESYS entirely.') .option('--ssh-user ', 'With --ssh-version: SSH user (default "karstein")') .option('--ssh-boot-app ', 'With --ssh-version: path to the boot application on the PLC (default /var/opt/codesys/PlcLogic/Application/Application.app)') + .option('--ide-bridge ', 'Attach to the CODESYS-shipped MCP bridge plugin via its named pipe and republish its tools with an `ide_` prefix. Modes: auto (default; try to attach, skip if absent), on (fail loudly if attach fails), off (disable).', 'auto') .parse(process.argv); const opts = program.opts(); @@ -213,6 +220,7 @@ if (opts.sshVersion) { approveEdits: opts.approveEdits || false, liveValues: opts.liveValues || false, liveValuesIntervalMs: clampInterval(opts.liveValuesInterval), + ideBridge: normaliseIdeBridge(opts.ideBridge), }; process.stderr.write(`Starting CODESYS MCP Server v${version}\n`); diff --git a/src/ide-bridge.ts b/src/ide-bridge.ts new file mode 100644 index 0000000..2645eb2 --- /dev/null +++ b/src/ide-bridge.ts @@ -0,0 +1,271 @@ +/** + * Subprocess client for the CODESYS-shipped MCP bridge (CodesysMCPBridge.exe + * shim that ships in CODESYS 3.5.22.10+ next to CODESYS.exe). The shim is + * the only piece that speaks MCP; it forwards each call to the in-IDE + * plugin via a private named pipe whose wire format is not MCP-compatible, + * so we go through the shim's stdio rather than the pipe directly. + * + * Why: the bridge plugin runs in-process inside CODESYS, so its authoring + * tools (create_or_replace_structured_text_object, browse_project_tree, ...) + * mutate the live project graph and the editor view picks the change up + * immediately. Our existing IronPython watcher modifies the project graph + * from a primary-thread script but never touches the editor layer, so POUs + * don't pop open as they change. Wrapping the bridge gives us that live view + * for free on SP22+, while keeping the watcher as the SP19/SP21 fallback and + * the home of every online/runtime/release tool the bridge doesn't ship. + */ + +import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import { z } from 'zod'; +import { serverLog } from './logger'; + +/** Tool descriptor returned by the bridge's tools/list. */ +export interface BridgeTool { + name: string; + description?: string; + inputSchema?: JsonSchema; +} + +/** Minimal subset of JSON Schema we recognise. Anything else falls back to z.unknown(). */ +export interface JsonSchema { + type?: string | string[]; + properties?: Record; + required?: string[]; + items?: JsonSchema | JsonSchema[]; + enum?: unknown[]; + description?: string; + default?: unknown; +} + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (err: Error) => void; + timer?: NodeJS.Timeout; +} + +/** + * JSON-RPC 2.0 client over a child-process stdio pair. The bridge shim + * (`CodesysMCPBridge.exe`) speaks newline-delimited MCP/JSON-RPC and + * forwards each call to the in-IDE plugin via its own named pipe. + */ +export class IdeBridgeClient { + private proc: ChildProcessWithoutNullStreams | null = null; + private nextId = 1; + private pending = new Map(); + private buffer = ''; + private connected = false; + + constructor(private readonly exePath: string) {} + + /** + * Default location of the bridge shim alongside CODESYS.exe. Returns null + * if the bridge isn't installed (SP19/SP21, or SP22+ before the user has + * upgraded the install to one that ships the bridge). + */ + static defaultExePath(codesysExePath: string): string | null { + // CODESYS.exe lives at /CODESYS/Common/CODESYS.exe; the bridge + // lives at /CODESYS/CodesysMCPBridge/CodesysMCPBridge.exe. + const installRoot = path.resolve(path.dirname(codesysExePath), '..'); + const candidate = path.join(installRoot, 'CodesysMCPBridge', 'CodesysMCPBridge.exe'); + return fs.existsSync(candidate) ? candidate : null; + } + + /** + * Spawn the bridge shim and wire up its stdio. Resolves once the process + * has started; the MCP `initialize` handshake is a separate call. + */ + async connect(timeoutMs = 2000): Promise { + if (this.connected) return; + if (!fs.existsSync(this.exePath)) { + throw new Error(`Bridge shim not found at ${this.exePath}`); + } + + await new Promise((resolve, reject) => { + const proc = spawn(this.exePath, [], { stdio: ['pipe', 'pipe', 'pipe'] }); + const timer = setTimeout(() => { + proc.kill(); + reject(new Error(`Bridge shim did not start within ${timeoutMs}ms: ${this.exePath}`)); + }, timeoutMs); + proc.once('error', (err) => { + clearTimeout(timer); + reject(err); + }); + proc.once('spawn', () => { + clearTimeout(timer); + this.proc = proc; + this.connected = true; + proc.stdout.on('data', (chunk) => this.onData(chunk)); + proc.stderr.on('data', (chunk) => { + // Bridge shim logs go through stderr — surface them for debugging + // but don't treat them as fatal. + const line = chunk.toString('utf8').trim(); + if (line) serverLog.debug(`[bridge] ${line}`); + }); + proc.on('exit', (code, signal) => this.onProcExit(code, signal)); + resolve(); + }); + }); + } + + /** + * MCP `initialize` handshake. Must be called once before any other request. + */ + async initialize(): Promise { + const result = (await this.request('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'codesys-mcp-sp21-plus/ide-bridge', version: '0.1' }, + })) as { protocolVersion?: string }; + serverLog.info(`Bridge initialize OK (protocolVersion=${result?.protocolVersion ?? 'unknown'})`); + // MCP requires a notification after initialize. + this.notify('notifications/initialized', {}); + } + + /** Fetch the bridge's tool list. */ + async listTools(): Promise { + const result = (await this.request('tools/list', {})) as { tools?: BridgeTool[] }; + return result?.tools ?? []; + } + + /** Call a bridge tool by name with raw arguments. Returns the bridge's result envelope unchanged. */ + async callTool(name: string, args: Record): Promise { + return await this.request('tools/call', { name, arguments: args }); + } + + /** Kill the bridge shim. Safe to call repeatedly. */ + close(): void { + if (!this.connected) return; + this.connected = false; + try { + this.proc?.stdin.end(); + } catch { + /* swallow */ + } + this.proc?.kill(); + this.proc = null; + for (const [, p] of this.pending) { + if (p.timer) clearTimeout(p.timer); + p.reject(new Error('Bridge connection closed')); + } + this.pending.clear(); + } + + private request(method: string, params: unknown, timeoutMs = 60_000): Promise { + if (!this.connected || !this.proc) { + return Promise.reject(new Error('Bridge not connected')); + } + const id = this.nextId++; + const payload = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Bridge request '${method}' timed out after ${timeoutMs}ms`)); + }, timeoutMs); + this.pending.set(id, { resolve, reject, timer }); + this.proc!.stdin.write(payload); + }); + } + + private notify(method: string, params: unknown): void { + if (!this.connected || !this.proc) return; + this.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n'); + } + + private onData(chunk: Buffer): void { + this.buffer += chunk.toString('utf8'); + let idx: number; + while ((idx = this.buffer.indexOf('\n')) >= 0) { + const line = this.buffer.slice(0, idx).trim(); + this.buffer = this.buffer.slice(idx + 1); + if (!line) continue; + this.dispatch(line); + } + } + + private dispatch(line: string): void { + let msg: { id?: number; result?: unknown; error?: { code: number; message: string } }; + try { + msg = JSON.parse(line); + } catch (err) { + serverLog.warn(`Bridge sent non-JSON line: ${line.slice(0, 200)}`); + return; + } + if (typeof msg.id !== 'number') return; // notification, ignore + const p = this.pending.get(msg.id); + if (!p) return; + this.pending.delete(msg.id); + if (p.timer) clearTimeout(p.timer); + if (msg.error) { + p.reject(new Error(`Bridge error ${msg.error.code}: ${msg.error.message}`)); + } else { + p.resolve(msg.result); + } + } + + private onProcExit(code: number | null, signal: NodeJS.Signals | null): void { + if (!this.connected) return; + this.connected = false; + const why = signal ? `signal ${signal}` : `exit ${code}`; + for (const [, p] of this.pending) { + if (p.timer) clearTimeout(p.timer); + p.reject(new Error(`Bridge shim exited (${why})`)); + } + this.pending.clear(); + } +} + +/** + * Minimal JSON-Schema-to-Zod-shape converter. The MCP SDK's `tool()` API takes + * a Record describing the args; the bridge gives us a full + * JSON-Schema object whose `properties` map onto exactly that shape. Anything + * we don't recognise falls back to z.unknown() so the call still goes through. + */ +export function bridgeSchemaToZodShape(schema: JsonSchema | undefined): Record { + const shape: Record = {}; + if (!schema || !schema.properties) return shape; + const required = new Set(schema.required ?? []); + for (const [key, propSchema] of Object.entries(schema.properties)) { + let zodType = jsonSchemaToZod(propSchema); + if (propSchema.description) zodType = zodType.describe(propSchema.description); + if (!required.has(key)) zodType = zodType.optional(); + shape[key] = zodType; + } + return shape; +} + +function jsonSchemaToZod(schema: JsonSchema): z.ZodTypeAny { + // Handle nullable via type-union `["string", "null"]`. + const type = Array.isArray(schema.type) ? schema.type.filter((t) => t !== 'null')[0] : schema.type; + const nullable = Array.isArray(schema.type) && schema.type.includes('null'); + let base: z.ZodTypeAny; + if (schema.enum && schema.enum.length > 0 && schema.enum.every((v) => typeof v === 'string')) { + base = z.enum(schema.enum as [string, ...string[]]); + } else { + switch (type) { + case 'string': + base = z.string(); + break; + case 'integer': + case 'number': + base = z.number(); + break; + case 'boolean': + base = z.boolean(); + break; + case 'array': { + const items = Array.isArray(schema.items) ? schema.items[0] : schema.items; + base = z.array(items ? jsonSchemaToZod(items) : z.unknown()); + break; + } + case 'object': + // For nested objects we don't recursively type — the bridge will validate. + base = z.record(z.unknown()); + break; + default: + base = z.unknown(); + } + } + return nullable ? base.nullable() : base; +} diff --git a/src/server.ts b/src/server.ts index dd3cb7b..882711c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -22,6 +22,7 @@ import { formatRestartRuntimeResult, } from './ssh-restart-runtime'; import { resolveMirrorRoot } from './mirror-paths'; +import { IdeBridgeClient, bridgeSchemaToZodShape } from './ide-bridge'; import { inspectProjectFile } from './inspect'; import { parseProfileName } from './detect'; import { decideOpenProjectPreflight } from './preflight'; @@ -3450,6 +3451,17 @@ export async function startMcpServer(config: ServerConfig): Promise { } ); + // ─── CODESYS IDE bridge passthrough (opt-in via --ide-bridge) ─────── + // When the CODESYS-shipped bridge plugin is loaded inside the running IDE + // (SP22+), it exposes a named pipe at \\.\pipe\codesys-mcp-bridge with its + // own MCP server. We attach to that pipe, fetch its tools/list, and + // re-register each tool under an `ide_` prefix. The bridge's authoring + // tools mutate the live project graph and the editor view picks the change + // up immediately, which our IronPython watcher can't do. + if (config.ideBridge !== 'off') { + await registerIdeBridgeTools(s, config.ideBridge, config.codesysPath); + } + // ─── Connect ───────────────────────────────────────────────────────── const transport = new StdioServerTransport(); @@ -3549,3 +3561,68 @@ export async function startMcpServer(config: ServerConfig): Promise { serverLog.error(`Unhandled rejection: ${reason}`); }); } + +/** + * Probe the CODESYS IDE bridge's named pipe and republish its tools under the + * `ide_` prefix on our own server. Quietly skips when the bridge plugin isn't + * present (SP19/SP21, or SP22+ before the user opens CODESYS) under mode='auto'; + * fails loudly under mode='on'. + */ +async function registerIdeBridgeTools( + s: any, + mode: 'on' | 'auto', + codesysPath: string +): Promise { + const exe = IdeBridgeClient.defaultExePath(codesysPath); + if (!exe) { + if (mode === 'on') { + throw new Error( + `--ide-bridge=on but no CodesysMCPBridge.exe found next to ${codesysPath} ` + + '(this CODESYS install does not ship the bridge — SP22.10+ required).' + ); + } + serverLog.info('IDE bridge shim not present on this CODESYS install; skipping (auto).'); + return; + } + const client = new IdeBridgeClient(exe); + try { + await client.connect(5000); + await client.initialize(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (mode === 'on') { + throw new Error(`--ide-bridge=on but failed to attach: ${msg}`); + } + serverLog.info(`IDE bridge not attached (auto): ${msg}`); + client.close(); + return; + } + let tools; + try { + tools = await client.listTools(); + } catch (err) { + serverLog.warn(`IDE bridge listTools failed: ${err instanceof Error ? err.message : err}`); + client.close(); + return; + } + serverLog.info(`IDE bridge attached. Registering ${tools.length} passthrough tool(s) with 'ide_' prefix.`); + for (const tool of tools) { + const prefixed = `ide_${tool.name}`; + const shape = bridgeSchemaToZodShape(tool.inputSchema); + const description = tool.description ?? `Passthrough to CODESYS IDE bridge tool '${tool.name}'.`; + s.tool(prefixed, description, shape, async (args: Record) => { + try { + const result = await client.callTool(tool.name, args ?? {}); + // The bridge returns an MCP result envelope ({ content: [...], isError }). + // Pass it through verbatim so the client sees exactly what the bridge sent. + return result as { content: Array<{ type: string; text: string }>; isError?: boolean }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + content: [{ type: 'text' as const, text: `Bridge call '${tool.name}' failed: ${msg}` }], + isError: true, + }; + } + }); + } +} diff --git a/src/types.ts b/src/types.ts index 8c3cf10..a695b5b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -85,6 +85,14 @@ export interface ServerConfig extends LauncherConfig { * Clamped to [100, 60000]; values outside the range are coerced. */ liveValuesIntervalMs?: number; + /** + * Whether to attach to the CODESYS-shipped MCP bridge's named pipe + * (`\\.\pipe\codesys-mcp-bridge`) and republish its tools under an `ide_` + * prefix on this server. Default 'auto' — try to attach, log and skip if + * the bridge plugin isn't loaded (SP19/SP21, or SP22+ before the user + * opens CODESYS). 'on' fails loudly if attach fails. 'off' disables. + */ + ideBridge: 'auto' | 'on' | 'off'; } /** Script template parameters */