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 */