From f42a41c4417150bd425b3bbff901fa96c405cd37 Mon Sep 17 00:00:00 2001 From: mixelpixx Date: Thu, 1 Oct 2026 06:15:28 -0400 Subject: [PATCH] feat(bridge): keep the stdio bridge useful while the IDE is closed - tools/list always returns the real tool list: the IDE's own once seen, otherwise the compiled definitions for the configured tool mode. - The read-only router tools (list_tool_categories, get_category_tools, search_tools) and prompts are answered locally. The server and the bridge now share runRouterDiscoveryTool so both answer identically. - With ARDUINO_AGENT_PATH set, a tool that needs the IDE starts it and waits for its MCP server; without it, the call returns an "open the IDE" tool error instead of failing to connect. - A watcher sends notifications/tools/list_changed when the IDE comes up or restarts with a different tool list. - Add a node:test suite (test/bridge, `yarn test:bridge`) and an offline-parity check to the manual smoke test; update the README, bridge README and skill. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 +- arduino-mcp-extension/bridge/README.md | 52 +- .../bridge/arduino-agent-bridge.js | 516 ++++++++++++++---- arduino-mcp-extension/package.json | 1 + .../src/common/mcp-tool-router.ts | 72 +++ arduino-mcp-extension/src/node/mcp-server.ts | 55 +- .../test/bridge/bridge.test.js | 312 +++++++++++ .../test/manual/smoke-test.js | 79 +++ skills/arduino-agent/SKILL.md | 5 +- 9 files changed, 924 insertions(+), 175 deletions(-) create mode 100644 arduino-mcp-extension/test/bridge/bridge.test.js diff --git a/README.md b/README.md index b08c25a618a..b1b2f24ccbd 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,10 @@ older builds are on the > **Tip — avoid "server failed to connect" when the IDE is closed.** The MCP > server lives *inside* the IDE, so a direct HTTP connection fails whenever - > the IDE isn't running. Use the bundled stdio bridge instead and the server - > always connects, reporting "Arduino Agent is not running" only when you - > actually call a tool — and recovering by itself once you launch the IDE: + > the IDE isn't running. Use the bundled stdio bridge instead: the server + > always connects with its full tool list, and with `ARDUINO_AGENT_PATH` set + > a tool that needs the IDE starts it and waits for it — it recovers by + > itself when the IDE starts or restarts: > > ```json > { diff --git a/arduino-mcp-extension/bridge/README.md b/arduino-mcp-extension/bridge/README.md index 88d8d891e5c..9e737259d9b 100644 --- a/arduino-mcp-extension/bridge/README.md +++ b/arduino-mcp-extension/bridge/README.md @@ -10,15 +10,17 @@ whole server shows up as **failed to connect** whenever the IDE happens to be closed — which looks like a broken integration rather than an idle one. This bridge is a stdio MCP server that your client spawns as a child process, so -**connecting always succeeds**. It forwards requests to the IDE when it's up, -and when it isn't, tool calls come back with a plain, actionable message: - -> Arduino Agent is not running, so the Arduino tools are unavailable. -> Launch the Arduino Agent IDE and try again — the connection recovers -> automatically, there is no need to restart this client. - -When you launch the IDE later, the next request reconnects on its own. **No -client restart required.** +**connecting always succeeds**, and the Arduino tools stay usable while the IDE +is closed: + +- The client always sees the real tool list, not an empty one. +- Browsing the tools (`list_tool_categories`, `get_category_tools`, + `search_tools`) and the prompts work without the IDE. +- A tool that needs the IDE **starts it and waits for it** when + `ARDUINO_AGENT_PATH` is set — the first call is just slower. Without it, the + call returns a plain "open the IDE" message. +- When the IDE comes up (or restarts) the bridge notices on its own and tells + the client if the tool list changed. **No client restart required.** ## Usage @@ -27,7 +29,8 @@ client restart required.** "mcpServers": { "arduino": { "command": "node", - "args": ["/path/to/arduino-mcp-extension/bridge/arduino-agent-bridge.js"] + "args": ["/path/to/arduino-mcp-extension/bridge/arduino-agent-bridge.js"], + "env": { "ARDUINO_AGENT_PATH": "/path/to/Arduino IDE executable" } } } } @@ -35,7 +38,9 @@ client restart required.** No dependencies (node builtins only) and no token setup — it reads `~/.arduinoIDE/mcp-token` itself, re-reading per request so it survives the IDE -regenerating the token on restart. +regenerating the token on restart. Run it from a **built** checkout or install +(`yarn build` in `arduino-mcp-extension`): the offline tool list and answers +come from the compiled extension in `lib/`. ## Options @@ -45,10 +50,12 @@ All optional, set as environment variables: |----------|---------|---------| | `ARDUINO_MCP_URL` | `http://127.0.0.1:3847/mcp` | MCP endpoint to forward to | | `ARDUINO_MCP_TOKEN` | *(reads the token file)* | Override the auth token | -| `ARDUINO_AGENT_PATH` | *(unset — never launches)* | Path to the IDE executable; when set, a tool call made while the IDE is closed starts it in the background (rate-limited to one attempt per minute). That call still reports "not running" — retry once the IDE is up. | +| `ARDUINO_AGENT_PATH` | *(unset — never launches)* | Path to the IDE executable. When set, a tool call that needs the IDE while it is closed starts it, waits for its MCP server and then runs the call. Concurrent calls share one launch; if the IDE never answers, the bridge doesn't start it again until it has been seen running. | +| `ARDUINO_MCP_LAUNCH_TIMEOUT` | `120` | Seconds to wait for a started IDE's MCP server | +| `ARDUINO_MCP_WATCH_INTERVAL` | `5` | Seconds between checks for the IDE coming up (a local TCP connect) | | `ARDUINO_MCP_DEBUG` | *(off)* | Set to `1` for verbose logging on stderr | -Example with auto-launch on Windows: +Example on Windows: ```json { @@ -67,10 +74,15 @@ Example with auto-launch on Windows: | Request | Response | |---------|----------| | `initialize` | Succeeds (answered locally) with the server's workflow `instructions` and the `prompts` capability, so the client connects fully featured | -| `tools/list` | The tools seen last time this bridge talked to the IDE, or `[]` on a cold start | -| `tools/call` | A tool result with `isError: true` and the message above | -| `resources/list`, `prompts/list` | Empty lists | -| `prompts/get` | The launch-the-IDE error | +| `tools/list` | The IDE's own list if this bridge has seen it, otherwise the compiled definitions for the tool mode in `~/.arduinoIDE/settings.json` (`arduino.mcp.toolMode`, router by default) | +| `tools/call` — `list_tool_categories`, `get_category_tools`, `search_tools` | Answered locally, identical to the server (both use `runRouterDiscoveryTool`) | +| `tools/call` — anything else | With `ARDUINO_AGENT_PATH`: starts the IDE, waits (sending progress notifications if the client asked for them), then runs the call. Otherwise, or if the IDE doesn't come up: a tool result with `isError: true` saying what to do | +| `prompts/list`, `prompts/get` | Answered locally from the compiled prompts | +| `resources/list`, `resources/templates/list` | Empty lists | + +When the IDE comes up, the bridge fetches its tool list and sends +`notifications/tools/list_changed` if it differs from what the client was given +(for example, the IDE runs in `direct` mode). The instructions are loaded from the compiled extension when present (source of truth) with an embedded fallback; the smoke test asserts bridge/server @@ -78,6 +90,10 @@ parity so the copies cannot drift silently. Session handling is automatic: if the IDE restarts and invalidates the session (HTTP 404) or rotates the token (HTTP 401), the bridge re-runs the handshake and -retries the request once. +retries the request once. A token that is still rejected comes back as a +readable tool error rather than a protocol failure. + +Tests: `yarn test:bridge` in `arduino-mcp-extension` (after `yarn build`) runs +the bridge against a fake IDE server. > stdout carries only the JSON-RPC stream; all diagnostics go to stderr. diff --git a/arduino-mcp-extension/bridge/arduino-agent-bridge.js b/arduino-mcp-extension/bridge/arduino-agent-bridge.js index 21ea54e3c6a..03b8220f549 100644 --- a/arduino-mcp-extension/bridge/arduino-agent-bridge.js +++ b/arduino-mcp-extension/bridge/arduino-agent-bridge.js @@ -9,11 +9,20 @@ * integration is broken rather than merely idle. * * This bridge is a stdio MCP server that the client spawns as a child process, - * so connecting always succeeds. It forwards requests to the IDE when it is up, - * and when it is not it answers tool calls with a plain, actionable error - * ("Arduino Agent is not running - launch the IDE") instead of dropping the - * connection. When the IDE starts or restarts later, the bridge reconnects on - * the next request with no client restart required. + * so connecting always succeeds, and it keeps the integration usable while the + * IDE is closed: + * - tools/list always returns the real tool list: the IDE's own list once it + * has been seen, otherwise the definitions from the compiled extension for + * the tool mode set in the IDE preferences (router by default). + * - The read-only router tools (list_tool_categories, get_category_tools, + * search_tools) and the prompts are answered locally from the same compiled + * code the IDE uses, so tool discovery works with the IDE closed. + * - Any other tool call starts the IDE (when ARDUINO_AGENT_PATH is set), waits + * for its MCP server and then runs the call - the first call is just slower. + * Without ARDUINO_AGENT_PATH it returns a plain "open the IDE" message. + * - A watcher notices when the IDE comes up (or restarts) and sends + * notifications/tools/list_changed if its tools differ from what the client + * was given, so the client refreshes without a restart. * * Deliberately dependency-free (node builtins only) so it can be run straight * from a checkout or a packaged install with `node arduino-agent-bridge.js`. @@ -29,12 +38,14 @@ * } * * Environment: - * ARDUINO_MCP_URL Full URL of the MCP endpoint (default http://127.0.0.1:3847/mcp) - * ARDUINO_MCP_TOKEN Auth token (default: read from ~/.arduinoIDE/mcp-token) - * ARDUINO_AGENT_PATH Path to the IDE executable; enables auto-launch on first - * tool call when the IDE is not running. Opt-in: unset - * means the bridge never starts anything. - * ARDUINO_MCP_DEBUG Set to 1 for verbose logging on stderr. + * ARDUINO_MCP_URL Full URL of the MCP endpoint (default http://127.0.0.1:3847/mcp) + * ARDUINO_MCP_TOKEN Auth token (default: read from ~/.arduinoIDE/mcp-token) + * ARDUINO_AGENT_PATH Path to the IDE executable; enables starting the IDE + * when a tool needs it. Opt-in: unset means the bridge + * never starts anything. + * ARDUINO_MCP_LAUNCH_TIMEOUT Seconds to wait for a started IDE (default 120). + * ARDUINO_MCP_WATCH_INTERVAL Seconds between checks for the IDE coming up (default 5). + * ARDUINO_MCP_DEBUG Set to 1 for verbose logging on stderr. * * NOTE: stdout carries the JSON-RPC stream and nothing else. All diagnostics go * to stderr, or they would corrupt the protocol. @@ -44,18 +55,31 @@ const http = require('http'); const https = require('https'); +const net = require('net'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { spawn } = require('child_process'); const PROTOCOL_VERSION = '2024-11-05'; -const BRIDGE_VERSION = '0.2.0'; +const BRIDGE_VERSION = '0.3.0'; const ENDPOINT = process.env.ARDUINO_MCP_URL || 'http://127.0.0.1:3847/mcp'; -const TOKEN_FILE = path.join(os.homedir(), '.arduinoIDE', 'mcp-token'); +const ARDUINO_DIR = path.join(os.homedir(), '.arduinoIDE'); +const TOKEN_FILE = path.join(ARDUINO_DIR, 'mcp-token'); +const SETTINGS_FILE = path.join(ARDUINO_DIR, 'settings.json'); const DEBUG = process.env.ARDUINO_MCP_DEBUG === '1'; -// Don't respawn the IDE on every call while it is still starting up. -const LAUNCH_COOLDOWN_MS = 60_000; + +function seconds(value, fallback) { + const n = Number(value); + return (Number.isFinite(n) && n > 0 ? n : fallback) * 1000; +} +const LAUNCH_TIMEOUT_MS = seconds(process.env.ARDUINO_MCP_LAUNCH_TIMEOUT, 120); +const WATCH_INTERVAL_MS = seconds(process.env.ARDUINO_MCP_WATCH_INTERVAL, 5); +const LAUNCH_POLL_MS = Math.min(1000, WATCH_INTERVAL_MS); +// Handshake and listing requests only; tool calls (e.g. a compile with +// wait:true) may legitimately take minutes and are never timed out here. +const QUICK_REQUEST_TIMEOUT_MS = 15_000; +const PROBE_TIMEOUT_MS = 1000; /** * Server instructions surfaced in the bridge's locally-answered initialize @@ -86,26 +110,25 @@ Firmware rules of thumb: - Never busy-loop without delay()/vTaskDelay() on ESP32 - a starved idle task trips the task watchdog and reboots the chip. - Serial.begin(115200) is the conventional rate; after flashing over native USB, wait ~1s for CDC re-enumeration before expecting output.`; -function loadInstructions() { +/** A module from the compiled extension next to this file, or null (unbuilt checkout). */ +function loadCompiled(name) { try { - // Prefer the compiled extension next to this file (source of truth). // eslint-disable-next-line global-require - const mod = require(path.join( - __dirname, - '..', - 'lib', - 'common', - 'mcp-instructions' - )); - if (mod && typeof mod.MCP_SERVER_INSTRUCTIONS === 'string') { - return mod.MCP_SERVER_INSTRUCTIONS; - } + return require(path.join(__dirname, '..', 'lib', 'common', name)); } catch { - // Unbuilt checkout - use the embedded copy. + return null; } - return FALLBACK_INSTRUCTIONS; } -const INSTRUCTIONS = loadInstructions(); + +const compiledInstructions = loadCompiled('mcp-instructions'); +const INSTRUCTIONS = + typeof compiledInstructions?.MCP_SERVER_INSTRUCTIONS === 'string' + ? compiledInstructions.MCP_SERVER_INSTRUCTIONS + : FALLBACK_INSTRUCTIONS; +// Tool and prompt definitions: the same data the IDE serves. +const toolDefs = loadCompiled('mcp-tools'); +const router = loadCompiled('mcp-tool-router'); +const promptDefs = loadCompiled('mcp-prompts'); function log(...args) { if (DEBUG) console.error('[arduino-bridge]', ...args); @@ -115,14 +138,40 @@ function warn(...args) { console.error('[arduino-bridge]', ...args); } -/** The message shown when the IDE is not reachable. */ +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** The message shown when a tool needs the IDE and it is not reachable. */ function offlineMessage(detail) { - return ( - 'Arduino Agent is not running, so the Arduino tools are unavailable.\n\n' + - 'Launch the Arduino Agent IDE and try again - the connection recovers ' + - 'automatically, there is no need to restart this client.\n\n' + - (detail ? `(details: ${detail})` : '') - ).trim(); + const lines = [ + 'Arduino Agent is not running, so this Arduino tool cannot run right now.', + '', + 'Open the Arduino Agent IDE and call the tool again - the connection ' + + 'recovers on its own; there is no need to restart this client.', + ]; + if (detail) { + lines.push('', `(${detail})`); + } else if (!process.env.ARDUINO_AGENT_PATH) { + lines.push( + '', + 'Tip: set ARDUINO_AGENT_PATH to the IDE executable in this MCP server\'s ' + + 'config and the bridge will start the IDE for you when a tool needs it.' + ); + } + return lines.join('\n'); +} + +/** A readable message for upstream failures other than "not running". */ +function upstreamErrorMessage(err) { + if (err && err.status === 401) { + return ( + 'Arduino Agent rejected the bridge\'s auth token. The IDE writes a fresh ' + + `token to ${TOKEN_FILE} when it starts; restart the IDE, or set ` + + 'ARDUINO_MCP_TOKEN in this MCP server\'s config if you use a custom token.' + ); + } + return `Arduino Agent bridge error: ${err ? err.message : 'unknown error'}`; } // --------------------------------------------------------------------------- @@ -133,9 +182,13 @@ class Upstream { constructor(endpoint) { this.url = new URL(endpoint); this.transport = this.url.protocol === 'https:' ? https : http; + this.port = Number(this.url.port) || (this.url.protocol === 'https:' ? 443 : 80); this.sessionId = null; this.initialized = false; - this.lastLaunchAttempt = 0; + this.handshake = null; // in-flight handshake, shared by concurrent callers + this.online = false; // last request reached the IDE + this.pendingLaunch = null; // in-flight launch-and-wait, shared by concurrent calls + this.launchGaveUp = false; // a launch timed out; don't open more windows until the IDE is seen } token() { @@ -148,8 +201,11 @@ class Upstream { } } - /** Raw JSON-RPC POST. Resolves with the parsed body (or null for 202s). */ - request(body) { + /** + * Raw JSON-RPC POST. Resolves with the parsed body (or null for 202s). + * `timeoutMs` bounds how long the upstream may stay silent (quick requests only). + */ + request(body, timeoutMs) { return new Promise((resolve, reject) => { const data = JSON.stringify(body); const headers = { @@ -176,12 +232,14 @@ class Upstream { let raw = ''; res.on('data', (c) => (raw += c)); res.on('end', () => { + this.markOnline(); if (res.statusCode === 404 || res.statusCode === 401) { // Session expired or token rotated (IDE restarted): re-handshake. this.reset(); return reject( Object.assign(new Error(`upstream status ${res.statusCode}`), { retryable: true, + status: res.statusCode, }) ); } @@ -190,71 +248,158 @@ class Upstream { }); } ); - req.on('error', (err) => - reject(Object.assign(err, { offline: isOffline(err) })) - ); + if (timeoutMs) { + req.setTimeout(timeoutMs, () => + req.destroy( + Object.assign(new Error(`no answer within ${timeoutMs / 1000}s`), { + code: 'ETIMEDOUT', + }) + ) + ); + } + req.on('error', (err) => { + const offline = isOffline(err); + if (offline) this.markOffline(); + reject(Object.assign(err, { offline })); + }); req.write(data); req.end(); }); } + markOnline() { + this.online = true; + this.launchGaveUp = false; + } + + markOffline() { + this.online = false; + this.reset(); + } + reset() { this.sessionId = null; this.initialized = false; } /** Performs the MCP handshake if it has not been done for this session. */ - async ensureSession() { - if (this.initialized) return; - await this.request({ - jsonrpc: '2.0', - id: `bridge-init-${Date.now()}`, - method: 'initialize', - params: { - protocolVersion: PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { name: 'arduino-agent-bridge', version: BRIDGE_VERSION }, + ensureSession() { + if (this.initialized) return Promise.resolve(); + if (!this.handshake) { + this.handshake = this.handshakeOnce().finally(() => { + this.handshake = null; + }); + } + return this.handshake; + } + + async handshakeOnce() { + await this.request( + { + jsonrpc: '2.0', + id: `bridge-init-${Date.now()}`, + method: 'initialize', + params: { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'arduino-agent-bridge', version: BRIDGE_VERSION }, + }, }, - }); - await this.request({ jsonrpc: '2.0', method: 'notifications/initialized' }); + QUICK_REQUEST_TIMEOUT_MS + ); + await this.request( + { jsonrpc: '2.0', method: 'notifications/initialized' }, + QUICK_REQUEST_TIMEOUT_MS + ); this.initialized = true; log('upstream session established', this.sessionId || '(no session id)'); } /** Handshake + send, retrying once if the session went stale. */ - async send(body) { + async send(body, timeoutMs) { try { await this.ensureSession(); - return await this.request(body); + return await this.request(body, timeoutMs); } catch (err) { if (err && err.retryable) { log('retrying after stale session'); await this.ensureSession(); - return await this.request(body); + return await this.request(body, timeoutMs); } throw err; } } + /** Whether anything is listening on the MCP port (cheap TCP connect, no auth). */ + probe() { + return new Promise((resolve) => { + const socket = net.connect({ host: this.url.hostname, port: this.port }); + const done = (up) => { + socket.destroy(); + resolve(up); + }; + socket.setTimeout(PROBE_TIMEOUT_MS, () => done(false)); + socket.once('connect', () => done(true)); + socket.once('error', () => done(false)); + }); + } + /** - * Best-effort launch of the IDE, only when ARDUINO_AGENT_PATH is set. - * Fire-and-forget: the caller still reports "not running" for this call. + * Starts the IDE (ARDUINO_AGENT_PATH) and waits until its MCP server answers. + * Concurrent callers share one launch. Resolves {ok} or {ok:false, reason}. */ - maybeLaunchIde() { + launchAndWait(onWaiting) { + if (!this.pendingLaunch) { + this.pendingLaunch = this.launchAndWaitOnce(onWaiting).finally(() => { + this.pendingLaunch = null; + }); + } + return this.pendingLaunch; + } + + async launchAndWaitOnce(onWaiting) { const exe = process.env.ARDUINO_AGENT_PATH; - if (!exe) return false; - const now = Date.now(); - if (now - this.lastLaunchAttempt < LAUNCH_COOLDOWN_MS) return false; - this.lastLaunchAttempt = now; + if (this.launchGaveUp) { + return { + ok: false, + reason: + 'it was already started once and its MCP server never answered - check that ' + + 'the MCP server is enabled in the IDE preferences (arduino.mcp.enabled / autoConnect)', + }; + } + let spawnError = null; try { - const child = spawn(exe, [], { detached: true, stdio: 'ignore' }); + const child = spawn(exe, [], { + cwd: path.dirname(exe), + detached: true, + stdio: 'ignore', + }); + // A missing or non-executable path is reported asynchronously. + child.once('error', (err) => { + spawnError = err; + }); child.unref(); - warn(`launching Arduino Agent: ${exe}`); - return true; + warn(`starting Arduino Agent: ${exe}`); } catch (err) { - warn(`failed to launch ${exe}: ${err.message}`); - return false; + spawnError = err; } + + const started = Date.now(); + while (Date.now() - started < LAUNCH_TIMEOUT_MS) { + if (spawnError) { + return { ok: false, reason: `could not start ${exe}: ${spawnError.message}` }; + } + if (await this.probe()) return { ok: true }; + if (onWaiting) onWaiting(Date.now() - started); + await sleep(LAUNCH_POLL_MS); + } + this.launchGaveUp = true; + return { + ok: false, + reason: + `started ${exe}, but its MCP server did not answer within ` + + `${LAUNCH_TIMEOUT_MS / 1000}s - check that the MCP server is enabled in the IDE preferences`, + }; } } @@ -291,14 +436,92 @@ function parseBody(raw) { return null; } +// --------------------------------------------------------------------------- +// Offline answers (from the compiled extension) +// --------------------------------------------------------------------------- + +/** The IDE's tool mode as persisted in its preferences (what it will serve on start). */ +function toolMode() { + try { + const text = fs.readFileSync(SETTINGS_FILE, 'utf8'); + const match = /"arduino\.mcp\.toolMode"\s*:\s*"(router|direct)"/.exec(text); + if (match) return match[1]; + } catch { + // no settings yet: the IDE default applies + } + return 'router'; +} + +/** Same shape as the server's tools/list entries. */ +function toTool(tool) { + return { + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + ...(tool.annotations ? { annotations: tool.annotations } : {}), + }; +} + +/** Tools to advertise while the IDE is unreachable. */ +function offlineTools() { + if (upstreamTools) return upstreamTools; + const defs = toolMode() === 'direct' ? toolDefs?.ARDUINO_TOOLS : router?.ROUTER_TOOLS; + return Array.isArray(defs) ? defs.map(toTool) : []; +} + +function canAnswerLocally(name) { + return ( + Array.isArray(router?.ROUTER_DISCOVERY_TOOLS) && + router.ROUTER_DISCOVERY_TOOLS.includes(name) && + typeof router.runRouterDiscoveryTool === 'function' + ); +} + +/** Answers a read-only router tool exactly as the server would. */ +function answerLocally(id, name, args) { + try { + const result = router.runRouterDiscoveryTool(name, args); + respond(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }); + } catch (err) { + respond(id, { + content: [{ type: 'text', text: JSON.stringify({ error: err.message }, null, 2) }], + isError: true, + }); + } +} + +function offlinePrompts() { + const prompts = Array.isArray(promptDefs?.MCP_PROMPTS) ? promptDefs.MCP_PROMPTS : []; + return prompts.map((p) => ({ name: p.name, description: p.description, arguments: p.arguments })); +} + +function offlinePrompt(id, params) { + const prompt = + typeof promptDefs?.findPrompt === 'function' ? promptDefs.findPrompt(params?.name) : null; + if (!prompt) { + return respondError( + id, + -32602, + promptDefs ? `Unknown prompt: ${params?.name}` : offlineMessage() + ); + } + respond(id, { + description: prompt.description, + messages: [ + { role: 'user', content: { type: 'text', text: prompt.build(params?.arguments ?? {}) } }, + ], + }); +} + // --------------------------------------------------------------------------- // Bridge // --------------------------------------------------------------------------- const upstream = new Upstream(ENDPOINT); -// Remembered from the last successful tools/list so the client still sees a -// sensible tool list while the IDE is closed. -let cachedTools = null; +// The IDE's own tool list, once it has been seen by this bridge. +let upstreamTools = null; +// JSON of the tool list last sent to the client (null: the client never asked). +let advertised = null; function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); @@ -317,6 +540,69 @@ function toolError(id, text) { respond(id, { content: [{ type: 'text', text }], isError: true }); } +function relay(id, response) { + if (!response) return respond(id, {}); + if (response.error) return respondError(id, response.error.code, response.error.message); + return respond(id, response.result ?? {}); +} + +async function listTools(id, msg) { + let tools = null; + try { + const response = await upstream.send(msg, QUICK_REQUEST_TIMEOUT_MS); + if (Array.isArray(response?.result?.tools)) { + tools = response.result.tools; + upstreamTools = tools; + } + } catch (err) { + log(`tools/list upstream failure: ${err.message}`); + } + if (!tools) tools = offlineTools(); + advertised = JSON.stringify(tools); + respond(id, { tools }); +} + +async function callTool(id, msg) { + const name = msg.params?.name; + try { + return relay(id, await upstream.send(msg)); + } catch (err) { + if (!(err.offline || isOffline(err))) return toolError(id, upstreamErrorMessage(err)); + log(`tools/call ${name}: IDE not reachable (${err.message})`); + } + + // The IDE is not running. + if (canAnswerLocally(name)) return answerLocally(id, name, msg.params?.arguments); + if (!process.env.ARDUINO_AGENT_PATH) return toolError(id, offlineMessage()); + + const progressToken = msg.params?._meta?.progressToken; + const onWaiting = + progressToken === undefined + ? null + : (elapsedMs) => + send({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { + progressToken, + progress: Math.round(elapsedMs / 1000), + total: LAUNCH_TIMEOUT_MS / 1000, + message: 'Starting Arduino Agent...', + }, + }); + const launch = await upstream.launchAndWait(onWaiting); + if (!launch.ok) return toolError(id, offlineMessage(`Tried to start it: ${launch.reason}`)); + + try { + return relay(id, await upstream.send(msg)); + } catch (err) { + return toolError( + id, + err.offline || isOffline(err) ? offlineMessage(err.message) : upstreamErrorMessage(err) + ); + } +} + async function handle(msg) { const { id, method, params } = msg; const isNotification = id === undefined || id === null; @@ -332,6 +618,7 @@ async function handle(msg) { }); // Warm the upstream session in the background; ignore failures. upstream.ensureSession().catch(() => undefined); + startWatcher(); return; } @@ -344,49 +631,74 @@ async function handle(msg) { return; } + if (method === 'tools/list' && !isNotification) return listTools(id, msg); + if (method === 'tools/call' && !isNotification) return callTool(id, msg); + try { - const response = await upstream.send(msg); + const response = await upstream.send(msg, QUICK_REQUEST_TIMEOUT_MS); if (isNotification) return; - if (!response) return respond(id, {}); - - if (method === 'tools/list' && response.result && response.result.tools) { - cachedTools = response.result.tools; - } - if (response.error) return respondError(id, response.error.code, response.error.message); - return respond(id, response.result ?? {}); + return relay(id, response); } catch (err) { const offline = err.offline || isOffline(err); log(`upstream failure on ${method}: ${err.message}`); if (isNotification) return; - if (!offline) { - return respondError(id, -32603, `Arduino Agent bridge error: ${err.message}`); - } + if (!offline) return respondError(id, -32603, upstreamErrorMessage(err)); // The IDE is down. Degrade gracefully instead of failing the connection. - if (method === 'tools/list') { - // Advertise the tools we saw last time so the client keeps working; - // calling one returns the friendly error below. - return respond(id, { tools: cachedTools || [] }); - } - if (method === 'tools/call') { - const launched = upstream.maybeLaunchIde(); - return toolError( - id, - offlineMessage( - launched ? 'starting Arduino Agent now - retry in a few seconds' : null - ) - ); - } + if (method === 'prompts/list') return respond(id, { prompts: offlinePrompts() }); + if (method === 'prompts/get') return offlinePrompt(id, params); if (method === 'resources/list') return respond(id, { resources: [] }); - if (method === 'prompts/list') return respond(id, { prompts: [] }); - if (method === 'prompts/get') { - return respondError(id, -32601, offlineMessage()); - } + if (method === 'resources/templates/list') return respond(id, { resourceTemplates: [] }); + if (method === 'logging/setLevel') return respond(id, {}); return respondError(id, -32603, offlineMessage()); } } +// --------------------------------------------------------------------------- +// Watcher: notice the IDE coming up (or restarting) without any client request +// --------------------------------------------------------------------------- + +let watcher = null; +let watching = false; + +function startWatcher() { + if (watcher) return; + watcher = setInterval(() => { + if (watching) return; + watching = true; + watchTick() + .catch((err) => log(`watch: ${err.message}`)) + .finally(() => { + watching = false; + }); + }, WATCH_INTERVAL_MS); + watcher.unref(); +} + +async function watchTick() { + if (!(await upstream.probe())) { + // Gone: the next start is a new server with a new session and token. + if (upstream.online) upstream.markOffline(); + return; + } + if (upstream.online) return; // already connected and current + + const response = await upstream.send( + { jsonrpc: '2.0', id: `bridge-watch-${Date.now()}`, method: 'tools/list' }, + QUICK_REQUEST_TIMEOUT_MS + ); + const tools = response?.result?.tools; + if (!Array.isArray(tools)) return; + upstreamTools = tools; + log('Arduino Agent is up'); + if (advertised !== null && JSON.stringify(tools) !== advertised) { + advertised = JSON.stringify(tools); + log('tool list changed; notifying client'); + send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' }); + } +} + // --------------------------------------------------------------------------- // stdio plumbing: newline-delimited JSON-RPC // --------------------------------------------------------------------------- diff --git a/arduino-mcp-extension/package.json b/arduino-mcp-extension/package.json index 87736301d1b..7ef11f60775 100644 --- a/arduino-mcp-extension/package.json +++ b/arduino-mcp-extension/package.json @@ -34,6 +34,7 @@ "scripts": { "clean": "rimraf lib", "build": "tsc", + "test:bridge": "node --test test/bridge/bridge.test.js", "build:dev": "yarn build", "watch": "tsc -w" } diff --git a/arduino-mcp-extension/src/common/mcp-tool-router.ts b/arduino-mcp-extension/src/common/mcp-tool-router.ts index 54ecee32ba4..eb34f09bc73 100644 --- a/arduino-mcp-extension/src/common/mcp-tool-router.ts +++ b/arduino-mcp-extension/src/common/mcp-tool-router.ts @@ -251,6 +251,78 @@ function calculateRelevance(tool: ToolDefinition, query: string): number { return score; } +/** + * The read-only router tools. They only describe the catalog, so the stdio + * bridge can answer them while the IDE is closed. + */ +export const ROUTER_DISCOVERY_TOOLS = [ + 'list_tool_categories', + 'get_category_tools', + 'search_tools', +]; + +/** + * Result of a router discovery tool (see ROUTER_DISCOVERY_TOOLS). Shared by the + * MCP server and the offline bridge so both answer identically. + * Throws on missing/unknown arguments, like any tool handler. + */ +export function runRouterDiscoveryTool( + name: string, + args: Record | undefined +): unknown { + switch (name) { + case 'list_tool_categories': + return { + categories: listToolCategories(), + hint: 'Use get_category_tools with a category name to see detailed tool info', + }; + + case 'get_category_tools': { + const category = args?.category as string; + if (!category) { + throw new Error('category is required'); + } + const tools = getCategoryTools(category); + if (!tools) { + throw new Error( + `Unknown category: ${category}. Use list_tool_categories to see available categories.` + ); + } + return { + category, + tools: tools.map((t) => ({ + name: t.name, + description: t.description, + parameters: t.inputSchema.properties, + required: t.inputSchema.required || [], + annotations: t.annotations, + })), + hint: 'Use execute_tool with tool_name and params to execute a tool', + }; + } + + case 'search_tools': { + const query = args?.query as string; + if (!query) { + throw new Error('query is required'); + } + const searchResults = searchTools(query); + return { + query, + results: searchResults, + count: searchResults.length, + hint: + searchResults.length > 0 + ? 'Use get_category_tools to see full tool details, or execute_tool to run a tool' + : 'No tools found. Try a different search term.', + }; + } + + default: + throw new Error(`Not a router discovery tool: ${name}`); + } +} + /** * Get a tool definition by name (for execute_tool validation) */ diff --git a/arduino-mcp-extension/src/node/mcp-server.ts b/arduino-mcp-extension/src/node/mcp-server.ts index 0bf24e1ebbb..ff45e211a43 100644 --- a/arduino-mcp-extension/src/node/mcp-server.ts +++ b/arduino-mcp-extension/src/node/mcp-server.ts @@ -56,9 +56,7 @@ import { } from '../common/mcp-service'; import { ROUTER_TOOLS, - listToolCategories, - getCategoryTools, - searchTools, + runRouterDiscoveryTool, toolExists, } from '../common/mcp-tool-router'; import { @@ -860,36 +858,11 @@ export class ArduinoMCPServer { if (this.useRouterMode) { switch (name) { case 'list_tool_categories': - result = { - categories: listToolCategories(), - hint: 'Use get_category_tools with a category name to see detailed tool info', - }; - break; - - case 'get_category_tools': { - const category = args?.category as string; - if (!category) { - throw new Error('category is required'); - } - const tools = getCategoryTools(category); - if (!tools) { - throw new Error( - `Unknown category: ${category}. Use list_tool_categories to see available categories.` - ); - } - result = { - category, - tools: tools.map((t) => ({ - name: t.name, - description: t.description, - parameters: t.inputSchema.properties, - required: t.inputSchema.required || [], - annotations: t.annotations, - })), - hint: 'Use execute_tool with tool_name and params to execute a tool', - }; + case 'get_category_tools': + case 'search_tools': + // Shared with the offline bridge (answers these with the IDE closed). + result = runRouterDiscoveryTool(name, args); break; - } case 'execute_tool': { const toolName = args?.tool_name as string; @@ -906,24 +879,6 @@ export class ArduinoMCPServer { break; } - case 'search_tools': { - const query = args?.query as string; - if (!query) { - throw new Error('query is required'); - } - const searchResults = searchTools(query); - result = { - query, - results: searchResults, - count: searchResults.length, - hint: - searchResults.length > 0 - ? 'Use get_category_tools to see full tool details, or execute_tool to run a tool' - : 'No tools found. Try a different search term.', - }; - break; - } - default: // Fall through to direct tool execution for backwards compatibility result = await this.executeArduinoTool(name, args || {}); diff --git a/arduino-mcp-extension/test/bridge/bridge.test.js b/arduino-mcp-extension/test/bridge/bridge.test.js new file mode 100644 index 00000000000..a56995fcd3a --- /dev/null +++ b/arduino-mcp-extension/test/bridge/bridge.test.js @@ -0,0 +1,312 @@ +/** + * Automated tests for the stdio bridge's behaviour while Arduino Agent is closed, + * starting, or running. A fake upstream stands in for the IDE's MCP server. + * + * yarn test:bridge (from arduino-mcp-extension, after `yarn build`) + * + * Node builtins only, like the bridge itself. + */ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const { spawn } = require('child_process'); +const fs = require('fs'); +const http = require('http'); +const net = require('net'); +const os = require('os'); +const path = require('path'); + +const BRIDGE = path.join(__dirname, '..', '..', 'bridge', 'arduino-agent-bridge.js'); +const LIB = path.join(__dirname, '..', '..', 'lib', 'common'); +const libBuilt = fs.existsSync(path.join(LIB, 'mcp-tool-router.js')); +const needsLib = { skip: libBuilt ? false : 'compiled extension missing - run yarn build first' }; +const TOKEN = 'test-token'; + +function freePort() { + return new Promise((resolve) => { + const s = net.createServer().listen(0, '127.0.0.1', () => { + const { port } = s.address(); + s.close(() => resolve(port)); + }); + }); +} + +/** Minimal stand-in for the IDE's Streamable HTTP MCP server. */ +function fakeIde(port, { tools, token = TOKEN } = {}) { + const calls = []; + const server = http.createServer((req, res) => { + let raw = ''; + req.on('data', (c) => (raw += c)); + req.on('end', () => { + if (req.headers.authorization !== `Bearer ${token}`) { + res.writeHead(401).end(); + return; + } + const msg = JSON.parse(raw); + if (msg.id === undefined) { + res.writeHead(202).end(); + return; + } + let result; + if (msg.method === 'initialize') { + result = { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'fake-ide' } }; + } else if (msg.method === 'tools/list') { + result = { tools }; + } else if (msg.method === 'tools/call') { + calls.push(msg.params.name); + result = { content: [{ type: 'text', text: `ran ${msg.params.name}` }] }; + } else { + result = {}; + } + res.writeHead(200, { 'Content-Type': 'application/json', 'Mcp-Session-Id': 'fake-session' }); + res.end(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result })); + }); + }); + return new Promise((resolve) => + server.listen(port, '127.0.0.1', () => + resolve({ calls, close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(r); }) }) + ) + ); +} + +/** Spawns the bridge with an isolated home directory and a JSON-RPC client around it. */ +function startBridge(port, env = {}, settings) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'arduino-bridge-test-')); + if (settings) { + fs.mkdirSync(path.join(home, '.arduinoIDE')); + fs.writeFileSync(path.join(home, '.arduinoIDE', 'settings.json'), JSON.stringify(settings)); + } + const childEnv = { + ...process.env, + HOME: home, + USERPROFILE: home, + ARDUINO_MCP_URL: `http://127.0.0.1:${port}/mcp`, + ARDUINO_MCP_TOKEN: TOKEN, + ARDUINO_MCP_WATCH_INTERVAL: '0.2', + ...env, + }; + for (const [k, v] of Object.entries(childEnv)) if (v === undefined) delete childEnv[k]; + const child = spawn(process.execPath, [BRIDGE], { env: childEnv, stdio: ['pipe', 'pipe', 'ignore'] }); + + const received = []; + const waiters = []; + let buffer = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + buffer += chunk; + let i; + while ((i = buffer.indexOf('\n')) !== -1) { + const msg = JSON.parse(buffer.slice(0, i)); + buffer = buffer.slice(i + 1); + received.push(msg); + for (const w of [...waiters]) if (w.match(msg)) { waiters.splice(waiters.indexOf(w), 1); w.resolve(msg); } + } + }); + + let nextId = 1; + const client = { + received, + waitFor(match, timeoutMs = 5000) { + const seen = received.find(match); + if (seen) return Promise.resolve(seen); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const at = waiters.indexOf(w); + if (at !== -1) { waiters.splice(at, 1); reject(new Error('timed out waiting for a bridge message')); } + }, timeoutMs); + const w = { match, resolve: (msg) => { clearTimeout(timer); resolve(msg); } }; + waiters.push(w); + }); + }, + request(method, params, timeoutMs) { + const id = nextId++; + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); + return client.waitFor((m) => m.id === id, timeoutMs); + }, + async init() { + await client.request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test' } }); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n'); + }, + close() { + child.kill(); + fs.rmSync(home, { recursive: true, force: true }); + }, + }; + return client; +} + +const textOf = (msg) => msg.result?.content?.[0]?.text ?? ''; +const names = (msg) => msg.result.tools.map((t) => t.name); +const router = () => require(path.join(LIB, 'mcp-tool-router.js')); + +test('offline: advertises the router tools from the compiled extension', needsLib, async () => { + const bridge = startBridge(await freePort()); + try { + await bridge.init(); + const list = await bridge.request('tools/list'); + assert.deepStrictEqual(names(list), router().ROUTER_TOOLS.map((t) => t.name)); + } finally { + bridge.close(); + } +}); + +test('offline: follows the direct tool mode set in the IDE preferences', needsLib, async () => { + const bridge = startBridge(await freePort(), {}, { 'arduino.mcp.toolMode': 'direct' }); + try { + await bridge.init(); + const list = await bridge.request('tools/list'); + const { ARDUINO_TOOLS } = require(path.join(LIB, 'mcp-tools.js')); + assert.deepStrictEqual(names(list), ARDUINO_TOOLS.map((t) => t.name)); + } finally { + bridge.close(); + } +}); + +test('offline: discovery tools are answered exactly like the server', needsLib, async () => { + const bridge = startBridge(await freePort()); + try { + await bridge.init(); + const found = await bridge.request('tools/call', { name: 'search_tools', arguments: { query: 'wait_for' } }); + assert.strictEqual(found.result.isError, undefined); + assert.strictEqual( + textOf(found), + JSON.stringify(router().runRouterDiscoveryTool('search_tools', { query: 'wait_for' }), null, 2) + ); + const bad = await bridge.request('tools/call', { name: 'get_category_tools', arguments: { category: 'nope' } }); + assert.strictEqual(bad.result.isError, true); + assert.match(textOf(bad), /Unknown category: nope/); + } finally { + bridge.close(); + } +}); + +test('offline: prompts still work', needsLib, async () => { + const bridge = startBridge(await freePort()); + try { + await bridge.init(); + const list = await bridge.request('prompts/list'); + assert.ok(list.result.prompts.some((p) => p.name === 'bringup')); + const got = await bridge.request('prompts/get', { name: 'bringup', arguments: {} }); + assert.match(got.result.messages[0].content.text, /arduino_board/); + } finally { + bridge.close(); + } +}); + +test('offline without ARDUINO_AGENT_PATH: a tool that needs the IDE says how to fix it, fast', async () => { + const bridge = startBridge(await freePort(), { ARDUINO_AGENT_PATH: undefined }); + try { + await bridge.init(); + const started = Date.now(); + const res = await bridge.request('tools/call', { name: 'execute_tool', arguments: { tool_name: 'arduino_context', params: {} } }); + assert.ok(Date.now() - started < 3000); + assert.strictEqual(res.result.isError, true); + assert.match(textOf(res), /not running/); + assert.match(textOf(res), /ARDUINO_AGENT_PATH/); + } finally { + bridge.close(); + } +}); + +test('IDE coming up sends tools/list_changed when its tools differ', async () => { + const port = await freePort(); + const bridge = startBridge(port); + let ide; + try { + await bridge.init(); + await bridge.request('tools/list'); + const ideTools = [{ name: 'arduino_context', description: 'ctx', inputSchema: { type: 'object', properties: {} } }]; + ide = await fakeIde(port, { tools: ideTools }); + await bridge.waitFor((m) => m.method === 'notifications/tools/list_changed', 3000); + const list = await bridge.request('tools/list'); + assert.deepStrictEqual(names(list), ['arduino_context']); + } finally { + bridge.close(); + await ide?.close(); + } +}); + +test('IDE coming up with the same tools sends no notification', needsLib, async () => { + const port = await freePort(); + const bridge = startBridge(port); + let ide; + try { + await bridge.init(); + const offline = await bridge.request('tools/list'); + ide = await fakeIde(port, { tools: offline.result.tools }); + await new Promise((r) => setTimeout(r, 1500)); + assert.ok(!bridge.received.some((m) => m.method === 'notifications/tools/list_changed')); + } finally { + bridge.close(); + await ide?.close(); + } +}); + +test('a tool call starts the IDE, waits for it, then runs', async () => { + const port = await freePort(); + // node.exe stands in for the IDE executable: it starts and exits; the fake + // server below plays the IDE's MCP server coming up a second later. + const bridge = startBridge(port, { ARDUINO_AGENT_PATH: process.execPath, ARDUINO_MCP_LAUNCH_TIMEOUT: '10' }); + let ide; + try { + await bridge.init(); + const pending = bridge.request('tools/call', { name: 'execute_tool', arguments: { tool_name: 'arduino_context', params: {} } }, 15000); + await new Promise((r) => setTimeout(r, 1000)); + ide = await fakeIde(port, { tools: [] }); + const res = await pending; + assert.strictEqual(res.result.isError, undefined); + assert.strictEqual(textOf(res), 'ran execute_tool'); + } finally { + bridge.close(); + await ide?.close(); + } +}); + +test('an IDE that never answers gives a clear error and is not started twice', async () => { + const bridge = startBridge(await freePort(), { ARDUINO_AGENT_PATH: process.execPath, ARDUINO_MCP_LAUNCH_TIMEOUT: '1' }); + try { + await bridge.init(); + const call = { name: 'execute_tool', arguments: { tool_name: 'arduino_context', params: {} } }; + const first = await bridge.request('tools/call', call); + assert.strictEqual(first.result.isError, true); + assert.match(textOf(first), /did not answer within 1s/); + const second = await bridge.request('tools/call', call); + assert.match(textOf(second), /already started once/); + } finally { + bridge.close(); + } +}); + +test('a wrong ARDUINO_AGENT_PATH is reported, not waited on', async () => { + const bridge = startBridge(await freePort(), { + ARDUINO_AGENT_PATH: path.join(os.tmpdir(), 'no-such-dir', 'Arduino IDE.exe'), + ARDUINO_MCP_LAUNCH_TIMEOUT: '10', + }); + try { + await bridge.init(); + const started = Date.now(); + const res = await bridge.request('tools/call', { name: 'execute_tool', arguments: { tool_name: 'x', params: {} } }); + assert.ok(Date.now() - started < 5000); + assert.match(textOf(res), /could not start/); + } finally { + bridge.close(); + } +}); + +test('a rejected token gives a readable tool error, and tools/list still answers', async () => { + const port = await freePort(); + const ide = await fakeIde(port, { tools: [], token: 'a-different-token' }); + const bridge = startBridge(port); + try { + await bridge.init(); + const res = await bridge.request('tools/call', { name: 'execute_tool', arguments: { tool_name: 'x', params: {} } }); + assert.strictEqual(res.result.isError, true); + assert.match(textOf(res), /rejected the bridge's auth token/); + const list = await bridge.request('tools/list'); + assert.ok(Array.isArray(list.result.tools)); + } finally { + bridge.close(); + await ide.close(); + } +}); diff --git a/arduino-mcp-extension/test/manual/smoke-test.js b/arduino-mcp-extension/test/manual/smoke-test.js index 921c1d91181..5b177982846 100644 --- a/arduino-mcp-extension/test/manual/smoke-test.js +++ b/arduino-mcp-extension/test/manual/smoke-test.js @@ -15,6 +15,8 @@ * 4. A Streamable HTTP session can be initialized at /mcp * 5. tools/list returns the router meta-tools * 6. execute_tool(arduino_context) returns IDE state + * 7. prompts, 8. search_tools, 9-10. bridge parity with the server (online + * instructions; offline tool list and discovery answers) */ const http = require('http'); @@ -295,6 +297,38 @@ async function main() { fail(`bridge parity check failed to run: ${err.message}`); } + // 10. Offline parity: what the bridge answers with the IDE unreachable must + // match the live server (tool list; router discovery answers). + try { + const offline = await bridgeOffline([ + { method: 'tools/list' }, + { method: 'tools/call', params: { name: 'search_tools', arguments: { query: 'wait_for' } } }, + ]); + const offlineNames = (offline[0].result?.tools ?? []).map((t) => t.name).join(', '); + const liveNames = tools.map((t) => t.name).join(', '); + if (offlineNames === liveNames) { + pass('bridge offline tools/list matches the server'); + } else { + fail(`bridge offline tools/list drift: [${offlineNames}] vs server [${liveNames}]`); + } + if (isRouter) { + const liveSearch = await mcpRequest(token, sessionId, { + jsonrpc: '2.0', + id: 7, + method: 'tools/call', + params: { name: 'search_tools', arguments: { query: 'wait_for' } }, + }); + const liveText = parsePayload(liveSearch).result?.content?.[0]?.text; + if (offline[1].result?.content?.[0]?.text === liveText) { + pass('bridge offline search_tools matches the server'); + } else { + fail('bridge offline search_tools differs from the server (rebuild lib, or the IDE is older than this checkout)'); + } + } + } catch (err) { + fail(`bridge offline parity check failed to run: ${err.message}`); + } + console.log(); if (failures) { fail(`${failures} check(s) failed`); @@ -350,6 +384,51 @@ function bridgeInitializeInstructions() { }); } +/** + * Runs the bridge against an unreachable endpoint (as if the IDE were closed) + * and returns its responses to `requests`, in order. + */ +function bridgeOffline(requests) { + const { spawn } = require('child_process'); + const bridgePath = path.join(__dirname, '..', '..', 'bridge', 'arduino-agent-bridge.js'); + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [bridgePath], { + stdio: ['pipe', 'pipe', 'ignore'], + env: { ...process.env, ARDUINO_MCP_URL: 'http://127.0.0.1:1/mcp', ARDUINO_AGENT_PATH: '' }, + }); + const timer = setTimeout(() => { + child.kill(); + reject(new Error('bridge did not answer within 10s')); + }, 10000); + const responses = new Map(); + let buffer = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + buffer += chunk; + let nl; + while ((nl = buffer.indexOf('\n')) !== -1) { + const msg = JSON.parse(buffer.slice(0, nl)); + buffer = buffer.slice(nl + 1); + if (typeof msg.id === 'number' && msg.id > 0) responses.set(msg.id, msg); + } + if (responses.size === requests.length) { + clearTimeout(timer); + child.kill(); + resolve(requests.map((_, i) => responses.get(i + 1))); + } + }); + child.on('error', (err) => { + clearTimeout(timer); + reject(err); + }); + const lines = [ + { jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'smoke-test', version: '1.0.0' } } }, + ...requests.map((r, i) => ({ jsonrpc: '2.0', id: i + 1, ...r })), + ]; + child.stdin.write(lines.map((l) => JSON.stringify(l)).join('\n') + '\n'); + }); +} + main().catch((err) => { fail(`Unexpected error: ${err.message}`); process.exit(1); diff --git a/skills/arduino-agent/SKILL.md b/skills/arduino-agent/SKILL.md index 9d2603f899d..ff14a48385b 100644 --- a/skills/arduino-agent/SKILL.md +++ b/skills/arduino-agent/SKILL.md @@ -17,8 +17,9 @@ skill adds the judgment that comes from real hardware sessions. If the MCP config points at `http://127.0.0.1:3847/mcp` directly, the whole server shows "failed to connect" whenever the IDE is closed. Prefer the stdio bridge (`arduino-mcp-extension/bridge/arduino-agent-bridge.js` in the repo or -install): it always connects, returns "launch the IDE" as a normal tool error, -and recovers without a client restart. +install): it always connects with the full tool list, starts the IDE when a tool +needs it (with `ARDUINO_AGENT_PATH` set; otherwise it returns "open the IDE" as +a normal tool error), and recovers without a client restart. ## Identifying boards: the decision tree