diff --git a/arduino-mcp-extension/bridge/arduino-agent-bridge.js b/arduino-mcp-extension/bridge/arduino-agent-bridge.js index c4a9e85e9f0..21ea54e3c6a 100644 --- a/arduino-mcp-extension/bridge/arduino-agent-bridge.js +++ b/arduino-mcp-extension/bridge/arduino-agent-bridge.js @@ -50,13 +50,63 @@ const path = require('path'); const { spawn } = require('child_process'); const PROTOCOL_VERSION = '2024-11-05'; -const BRIDGE_VERSION = '0.1.0'; +const BRIDGE_VERSION = '0.2.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 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; +/** + * Server instructions surfaced in the bridge's locally-answered initialize + * (the bridge must answer even when the IDE is closed). The compiled + * extension is the source of truth; the embedded string below is only the + * fallback for unbuilt checkouts. + * + * KEEP IN SYNC with src/common/mcp-instructions.ts - the manual smoke test + * compares the bridge's initialize.instructions with the HTTP server's. + */ +const FALLBACK_INSTRUCTIONS = `Arduino Agent - an Arduino IDE with this MCP server embedded. You share one editor, one board and one serial monitor with the user; prefer these tools over asking the user to click in the IDE. + +Recommended workflow: +1. arduino_context for current state (open sketch, selected board/port, connected boards with USB vid/pid). +2. If the board shows identified:false, run arduino_board suggest_fqbn (uses USB identity + name matching; tells you which core to install if missing). ALWAYS pass an explicit fqbn to compile/upload/serial connect for such boards - they can never be auto-identified. +3. Write code with arduino_sketch (set_content writes to disk AND live-reloads the user's editor). +4. Compile/upload with wait:true - one call returns the final result. A timed_out:true response is NOT an error: call arduino_task_status {task_id, wait:true} to keep waiting (first builds for a new core can take minutes). +5. arduino_upload compiles first automatically - no separate compile needed. +6. After upload, connect serial at the baud rate in the sketch's Serial.begin(). Reads are cursor-based: keep the returned cursor and pass it as since to page output losslessly. Use wait_for {pattern} to block until expected output arrives. +7. Crash/reset detection is automatic: read/wait_for responses carry events (reset/panic/watchdog/brownout/abort, with reset reasons and backtraces). A crash ends a pending wait_for early. "No output" plus reset events means the board is crash-looping, not quiet. + +Failure handling: +- Failed compile/upload tasks carry result.explained - read it before retrying; it names the cause and the fix (port busy, bootloader mode, wrong FQBN, power). +- Native-USB boards (ESP32-S2/S3/C3) re-enumerate after reset: the port can change or vanish; re-run arduino_board list_connected. Uploads are far more reliable via a board's UART/bridge port; native-USB uploads can require holding BOOT while pressing RESET. +- Serial port busy on upload usually means this server's own monitor is connected: arduino_serial disconnect first. + +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() { + 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; + } + } catch { + // Unbuilt checkout - use the embedded copy. + } + return FALLBACK_INSTRUCTIONS; +} +const INSTRUCTIONS = loadInstructions(); + function log(...args) { if (DEBUG) console.error('[arduino-bridge]', ...args); } @@ -276,8 +326,9 @@ async function handle(msg) { if (method === 'initialize') { respond(id, { protocolVersion: params?.protocolVersion || PROTOCOL_VERSION, - capabilities: { tools: { listChanged: true } }, + capabilities: { tools: { listChanged: true }, prompts: {} }, serverInfo: { name: 'arduino-agent-bridge', version: BRIDGE_VERSION }, + instructions: INSTRUCTIONS, }); // Warm the upstream session in the background; ignore failures. upstream.ensureSession().catch(() => undefined); @@ -329,6 +380,9 @@ async function handle(msg) { } 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()); + } return respondError(id, -32603, offlineMessage()); } } diff --git a/arduino-mcp-extension/src/common/mcp-instructions.ts b/arduino-mcp-extension/src/common/mcp-instructions.ts new file mode 100644 index 00000000000..b17fd88e23e --- /dev/null +++ b/arduino-mcp-extension/src/common/mcp-instructions.ts @@ -0,0 +1,31 @@ +/** + * Server instructions sent to every MCP client in the initialize response. + * + * This is the one guidance channel that reaches ALL clients regardless of + * vendor, so it carries the workflow knowledge an agent needs to drive real + * hardware well. Learned from driving a physical ESP32-S3 through these + * tools; keep it short - it lands in the client's context on every session. + * + * KEEP IN SYNC with the copy embedded in bridge/arduino-agent-bridge.js + * (the stdio bridge must answer initialize locally when the IDE is closed). + * The manual smoke test asserts the two stay identical. + */ +export const MCP_SERVER_INSTRUCTIONS = `Arduino Agent - an Arduino IDE with this MCP server embedded. You share one editor, one board and one serial monitor with the user; prefer these tools over asking the user to click in the IDE. + +Recommended workflow: +1. arduino_context for current state (open sketch, selected board/port, connected boards with USB vid/pid). +2. If the board shows identified:false, run arduino_board suggest_fqbn (uses USB identity + name matching; tells you which core to install if missing). ALWAYS pass an explicit fqbn to compile/upload/serial connect for such boards - they can never be auto-identified. +3. Write code with arduino_sketch (set_content writes to disk AND live-reloads the user's editor). +4. Compile/upload with wait:true - one call returns the final result. A timed_out:true response is NOT an error: call arduino_task_status {task_id, wait:true} to keep waiting (first builds for a new core can take minutes). +5. arduino_upload compiles first automatically - no separate compile needed. +6. After upload, connect serial at the baud rate in the sketch's Serial.begin(). Reads are cursor-based: keep the returned cursor and pass it as since to page output losslessly. Use wait_for {pattern} to block until expected output arrives. +7. Crash/reset detection is automatic: read/wait_for responses carry events (reset/panic/watchdog/brownout/abort, with reset reasons and backtraces). A crash ends a pending wait_for early. "No output" plus reset events means the board is crash-looping, not quiet. + +Failure handling: +- Failed compile/upload tasks carry result.explained - read it before retrying; it names the cause and the fix (port busy, bootloader mode, wrong FQBN, power). +- Native-USB boards (ESP32-S2/S3/C3) re-enumerate after reset: the port can change or vanish; re-run arduino_board list_connected. Uploads are far more reliable via a board's UART/bridge port; native-USB uploads can require holding BOOT while pressing RESET. +- Serial port busy on upload usually means this server's own monitor is connected: arduino_serial disconnect first. + +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.`; diff --git a/arduino-mcp-extension/src/common/mcp-prompts.ts b/arduino-mcp-extension/src/common/mcp-prompts.ts new file mode 100644 index 00000000000..7449706bed0 --- /dev/null +++ b/arduino-mcp-extension/src/common/mcp-prompts.ts @@ -0,0 +1,89 @@ +/** + * MCP prompts - reusable workflows surfaced by clients as slash commands + * (Claude Code shows them as /arduino:bringup etc.). Each returns messages + * that put the agent on the proven path for a common hardware task. + */ + +export interface MCPPromptArgument { + name: string; + description: string; + required: boolean; +} + +export interface MCPPromptDefinition { + name: string; + description: string; + arguments: MCPPromptArgument[]; + /** Builds the prompt messages from the (string) arguments. */ + build(args: Record): string; +} + +export const MCP_PROMPTS: MCPPromptDefinition[] = [ + { + name: 'bringup', + description: + 'Bring up a connected board from scratch: identify it, install its core if needed, flash a minimal sketch and prove it runs.', + arguments: [ + { + name: 'port', + description: 'Serial port to use (omit to auto-detect)', + required: false, + }, + ], + build: (args) => `Bring up the Arduino-compatible board${ + args.port ? ` on port ${args.port}` : '' + } end to end: + +1. arduino_board list_connected - find the board${ + args.port ? ` on ${args.port}` : ' (pick the most likely port)' + }. Note identified and the USB vid/pid. +2. If identified:false, arduino_board suggest_fqbn with the port (add a name if the user gave one). Install the suggested core with install_core if core_to_install is set - this can take minutes for large cores like esp32. +3. Create a minimal heartbeat sketch: blink LED_BUILTIN and Serial.println a counter once per second at 115200 baud. No busy-loops without delay(). +4. arduino_upload with wait:true and the explicit fqbn. If it fails, read result.explained and follow the suggestion (bridge port vs native USB, BOOT button, port busy). +5. arduino_serial connect at 115200 (pass the fqbn), then wait_for the heartbeat. Report the board model, chosen FQBN, and proof of life. If events show resets, diagnose before declaring success.`, + }, + { + name: 'debug-serial', + description: + 'Capture and diagnose what a board is doing over serial - including crashes, reboots and watchdogs.', + arguments: [ + { + name: 'port', + description: 'Serial port (omit to use the selected one)', + required: false, + }, + { + name: 'baud', + description: 'Baud rate (default 115200)', + required: false, + }, + ], + build: (args) => `Diagnose the board's serial output${ + args.port ? ` on ${args.port}` : '' + }: + +1. arduino_serial connect at ${ + args.baud || '115200' + } (pass an explicit fqbn if the board is unidentified). If output is garbage, the sketch's Serial.begin() rate differs - try common rates (9600, 115200). +2. Capture with cursor-based reads: read, keep the cursor, pass it as since on the next read - never lose lines. For expected output use wait_for {pattern}. +3. Watch the events field on every response. reset events carry the reason (POWERON, RTC_SW_CPU_RST, TG1WDT_SYS_RST...); panic events carry the Guru Meditation cause and the backtrace as detail. Repeated resets = crash loop. +4. For a panic backtrace, map addresses to the sketch: recompile with wait:true, and reason about which function likely faulted from the sketch source. +5. Report: what the board prints, any crash/reset events with their causes, and a concrete fix (watchdog-starving loop, null pointer, brownout = power supply, wrong baud).`, + }, + { + name: 'profile-board', + description: + 'Run the bundled self-profile example: the board measures its own CPU, memory bandwidth and thermal response, no wiring needed.', + arguments: [], + build: () => `Profile the connected ESP32-class board using the bundled example (needs nothing but USB): + +1. arduino_sketch list_examples with category "99.ArduinoAgent", then from_example on ESP32_SelfProfile. +2. arduino_upload with wait:true (explicit fqbn if unidentified; the example targets ESP32-family boards). +3. arduino_serial connect at 115200, then wait_for "PROFILE_START". Page output with cursor-based reads until "PROFILE_END" (~80s: chip/memory/cpu JSON, then a 68-sample thermal curve across idle -> both-cores-100% -> cooldown). +4. Parse the JSON lines and report: chip model/revision, flash/PSRAM sizes, internal-SRAM vs PSRAM bandwidth ratio, integer/float Mops at both clock speeds, and the thermal delta under load with recovery time. Watch events for watchdog/resets - the load phase is watchdog-safe by design, so any crash is a real finding.`, + }, +]; + +export function findPrompt(name: string): MCPPromptDefinition | undefined { + return MCP_PROMPTS.find((p) => p.name === name); +} diff --git a/arduino-mcp-extension/src/node/mcp-server.ts b/arduino-mcp-extension/src/node/mcp-server.ts index 7c4b3e70081..0bf24e1ebbb 100644 --- a/arduino-mcp-extension/src/node/mcp-server.ts +++ b/arduino-mcp-extension/src/node/mcp-server.ts @@ -36,6 +36,8 @@ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { CallToolRequestSchema, + GetPromptRequestSchema, + ListPromptsRequestSchema, ListToolsRequestSchema, Tool, } from '@modelcontextprotocol/sdk/types.js'; @@ -44,6 +46,8 @@ import { SERIAL_BAUD_RATES, ToolDefinition, } from '../common/mcp-tools'; +import { MCP_SERVER_INSTRUCTIONS } from '../common/mcp-instructions'; +import { MCP_PROMPTS, findPrompt } from '../common/mcp-prompts'; import { Task } from '../common/mcp-types'; import { MCPStatus, @@ -801,7 +805,11 @@ export class ArduinoMCPServer { private createMCPServerInstance(): Server { const server = new Server( { name: 'arduino-ide-mcp', version: EXTENSION_VERSION }, - { capabilities: { tools: {} } } + { + capabilities: { tools: {}, prompts: {} }, + // Workflow guidance delivered to every client at initialize. + instructions: MCP_SERVER_INSTRUCTIONS, + } ); const toTool = (tool: ToolDefinition): Tool => ({ @@ -817,6 +825,31 @@ export class ArduinoMCPServer { return { tools: tools.map(toTool) }; }); + server.setRequestHandler(ListPromptsRequestSchema, async () => ({ + prompts: MCP_PROMPTS.map((p) => ({ + name: p.name, + description: p.description, + arguments: p.arguments, + })), + })); + + server.setRequestHandler(GetPromptRequestSchema, async (request) => { + const prompt = findPrompt(request.params.name); + if (!prompt) { + throw new Error(`Unknown prompt: ${request.params.name}`); + } + const args = (request.params.arguments ?? {}) as Record; + return { + description: prompt.description, + messages: [ + { + role: 'user' as const, + content: { type: 'text' as const, text: prompt.build(args) }, + }, + ], + }; + }); + server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; mcpLog.info(`tools/call: ${name}`); diff --git a/arduino-mcp-extension/test/manual/smoke-test.js b/arduino-mcp-extension/test/manual/smoke-test.js index f08578ea44a..921c1d91181 100644 --- a/arduino-mcp-extension/test/manual/smoke-test.js +++ b/arduino-mcp-extension/test/manual/smoke-test.js @@ -175,11 +175,24 @@ async function main() { }, }); const sessionId = initResponse.headers['mcp-session-id']; + let serverInstructions = ''; if (initResponse.status === 200 && sessionId) { const initPayload = parsePayload(initResponse); pass( `initialized session ${sessionId} (server: ${initPayload.result?.serverInfo?.name})` ); + // 4b. instructions + prompts capability + serverInstructions = initPayload.result?.instructions ?? ''; + if (serverInstructions.length > 100) { + pass(`initialize carries instructions (${serverInstructions.length} chars)`); + } else { + fail('initialize result has no (or trivial) instructions'); + } + if (initPayload.result?.capabilities?.prompts) { + pass('prompts capability advertised'); + } else { + fail('prompts capability missing from initialize result'); + } } else { fail(`initialize failed with status ${initResponse.status}: ${initResponse.body}`); process.exit(1); @@ -225,6 +238,63 @@ async function main() { fail(`arduino_context failed: ${JSON.stringify(contextPayload)}`); } + // 7. prompts/list + prompts/get + const promptsResponse = await mcpRequest(token, sessionId, { + jsonrpc: '2.0', + id: 4, + method: 'prompts/list', + }); + const prompts = parsePayload(promptsResponse).result?.prompts ?? []; + if (prompts.length >= 3) { + pass(`prompts/list returned ${prompts.length}: ${prompts.map((p) => p.name).join(', ')}`); + } else { + fail(`prompts/list returned ${prompts.length} prompts (expected >= 3)`); + } + const bringupResponse = await mcpRequest(token, sessionId, { + jsonrpc: '2.0', + id: 5, + method: 'prompts/get', + params: { name: 'bringup', arguments: {} }, + }); + const bringup = parsePayload(bringupResponse).result; + if (bringup?.messages?.[0]?.content?.text?.includes('arduino_board')) { + pass('prompts/get bringup returns workflow messages'); + } else { + fail(`prompts/get bringup unexpected: ${JSON.stringify(bringup).slice(0, 200)}`); + } + + // 8. search_tools finds the new serial capabilities (router mode only) + if (isRouter) { + const searchResponse = await mcpRequest(token, sessionId, { + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { name: 'search_tools', arguments: { query: 'wait_for' } }, + }); + const searchText = + parsePayload(searchResponse).result?.content?.[0]?.text ?? ''; + if (searchText.includes('arduino_serial')) { + pass('search_tools "wait_for" surfaces arduino_serial'); + } else { + fail('search_tools "wait_for" did not surface arduino_serial'); + } + } + + // 9. Bridge parity: its locally-answered initialize must carry the same + // instructions the HTTP server sends (drift guard for the embedded copy). + try { + const bridgeInstructions = await bridgeInitializeInstructions(); + if (bridgeInstructions === serverInstructions) { + pass('bridge initialize.instructions matches the server'); + } else { + fail( + `bridge instructions drift: bridge ${bridgeInstructions.length} chars vs server ${serverInstructions.length} chars` + ); + } + } catch (err) { + fail(`bridge parity check failed to run: ${err.message}`); + } + console.log(); if (failures) { fail(`${failures} check(s) failed`); @@ -233,6 +303,53 @@ async function main() { pass('All checks passed'); } +/** Spawns the stdio bridge, sends initialize, returns its instructions. */ +function bridgeInitializeInstructions() { + const { spawn } = require('child_process'); + const path = require('path'); + 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'], + }); + const timer = setTimeout(() => { + child.kill(); + reject(new Error('bridge did not answer initialize within 10s')); + }, 10000); + let buffer = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + buffer += chunk; + const nl = buffer.indexOf('\n'); + if (nl === -1) return; + clearTimeout(timer); + child.kill(); + try { + const msg = JSON.parse(buffer.slice(0, nl)); + resolve(msg.result?.instructions ?? ''); + } catch (err) { + reject(err); + } + }); + child.on('error', (err) => { + clearTimeout(timer); + reject(err); + }); + child.stdin.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'smoke-test', version: '1.0.0' }, + }, + }) + '\n' + ); + }); +} + main().catch((err) => { fail(`Unexpected error: ${err.message}`); process.exit(1);