Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions arduino-mcp-extension/bridge/arduino-agent-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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());
}
}
Expand Down
31 changes: 31 additions & 0 deletions arduino-mcp-extension/src/common/mcp-instructions.ts
Original file line number Diff line number Diff line change
@@ -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.`;
89 changes: 89 additions & 0 deletions arduino-mcp-extension/src/common/mcp-prompts.ts
Original file line number Diff line number Diff line change
@@ -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, string>): 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);
}
35 changes: 34 additions & 1 deletion arduino-mcp-extension/src/node/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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 => ({
Expand All @@ -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<string, string>;
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}`);
Expand Down
Loading