From 2b2f8ba6bd94577b43fd1d089fec37f183378512 Mon Sep 17 00:00:00 2001 From: mixelpixx Date: Mon, 27 Jul 2026 15:08:04 -0400 Subject: [PATCH] feat(mcp): wait option for compile, upload and task status Every compile and upload previously forced the caller into a polling loop: the tool returned a taskId immediately and the agent had to call arduino_task_status repeatedly - the single biggest source of orchestration overhead when driving the tools (a real hardware session rewrote the same polling loop more than ten times). arduino_compile and arduino_upload now accept wait:true plus timeout_seconds (default 60, clamped 5-600). The dispatch keeps each runner's completion promise (they were fired through setImmediate and discarded), and waitForTask races it against the timeout: - finished: the full terminal result in one call - timeout: {taskId, status, progress, progressMessage, timed_out: true} with a hint - deliberately NOT an error, so a long first build simply gets re-waited via arduino_task_status {wait:true}, which now blocks on in-flight tasks the same way. Tasks also gain createdAt/startedAt/finishedAt timestamps, and pruneTasks clears the promise map and treats 'cancelled' as evictable. Verified on the ESP32-S3: 5s-timeout compile returned timed_out at progress 12 with the hint; re-wait completed with sizes; warm compile and a full upload each finished in a single wait:true call. Co-Authored-By: Claude Opus 4.8 --- arduino-mcp-extension/README.md | 11 +- .../src/common/mcp-tool-router.ts | 3 +- arduino-mcp-extension/src/common/mcp-tools.ts | 36 +++++- arduino-mcp-extension/src/common/mcp-types.ts | 6 + arduino-mcp-extension/src/node/mcp-server.ts | 106 +++++++++++++++++- 5 files changed, 152 insertions(+), 10 deletions(-) diff --git a/arduino-mcp-extension/README.md b/arduino-mcp-extension/README.md index bb3f1d0a221..73f865b818b 100644 --- a/arduino-mcp-extension/README.md +++ b/arduino-mcp-extension/README.md @@ -328,8 +328,13 @@ requires the bearer token (unless `arduino.mcp.requireAuth` is disabled). | `sketch_path` | Path to sketch (defaults to current) | | `fqbn` | Fully Qualified Board Name | | `verbose` | Enable verbose output | +| `wait` | Block until the build finishes (recommended) | +| `timeout_seconds` | Max seconds to block when `wait=true` (default 60, clamp 5-600) | -Returns a task ID. Use `arduino_task_status` to monitor progress. +With `wait:true` the call returns the final result in one shot; if the timeout +elapses first you get the current progress plus `timed_out: true` (not an +error) and can keep waiting with `arduino_task_status {wait:true}`. Without +`wait` it returns a task ID immediately. ### arduino_upload @@ -339,6 +344,8 @@ Returns a task ID. Use `arduino_task_status` to monitor progress. | `fqbn` | Fully Qualified Board Name | | `port` | Serial port (e.g., /dev/ttyUSB0, COM3) | | `verify` | Verify after upload | +| `wait` | Block until compile+flash finishes (recommended) | +| `timeout_seconds` | Max seconds to block when `wait=true` | **Note:** This operation overwrites firmware on the target device. @@ -402,6 +409,8 @@ Returns current IDE state including: | Parameter | Description | |-----------|-------------| | `task_id` | Task ID from compile or upload operation | +| `wait` | Block until the task reaches a terminal status | +| `timeout_seconds` | Max seconds to block when `wait=true` | Returns task status: `pending`, `running`, `completed`, `failed`, or `cancelled`. diff --git a/arduino-mcp-extension/src/common/mcp-tool-router.ts b/arduino-mcp-extension/src/common/mcp-tool-router.ts index aaafd90df08..7b71006ea98 100644 --- a/arduino-mcp-extension/src/common/mcp-tool-router.ts +++ b/arduino-mcp-extension/src/common/mcp-tool-router.ts @@ -37,7 +37,8 @@ export const TOOL_CATEGORIES: ToolCategory[] = [ name: 'build', description: 'Compile sketches, upload to boards, check build output and errors.', toolNames: ['arduino_compile', 'arduino_upload', 'arduino_build_output', 'arduino_task_status'], - useWhen: 'Building, uploading, checking compilation errors, monitoring async tasks', + useWhen: + 'Building, uploading, checking compilation errors. Pass wait:true to compile/upload to block until done instead of polling task status', }, { name: 'board', diff --git a/arduino-mcp-extension/src/common/mcp-tools.ts b/arduino-mcp-extension/src/common/mcp-tools.ts index d3ca1d15462..7f8a7f7a809 100644 --- a/arduino-mcp-extension/src/common/mcp-tools.ts +++ b/arduino-mcp-extension/src/common/mcp-tools.ts @@ -110,7 +110,7 @@ export const ARDUINO_TOOLS: ToolDefinition[] = [ { name: 'arduino_compile', description: - 'Compile a sketch. Returns immediately with a task ID; use arduino_task_status to check progress and get results, and arduino_build_output for the compiler output. Compiles the sketch open in the IDE unless sketch_path is given; uses the board selected in the IDE unless fqbn is given.', + 'Compile a sketch. Recommended: pass wait:true to block until the build finishes and get the result in one call. Without wait it returns a task ID immediately; use arduino_task_status to poll and arduino_build_output for the compiler output. Compiles the sketch open in the IDE unless sketch_path is given; uses the board selected in the IDE unless fqbn is given.', inputSchema: { type: 'object', properties: { @@ -128,6 +128,16 @@ export const ARDUINO_TOOLS: ToolDefinition[] = [ type: 'boolean', description: 'Show verbose compilation output (default: false)', }, + wait: { + type: 'boolean', + description: + 'Block until the build finishes or timeout_seconds elapses (default: false). On timeout you get the taskId and current progress back - not an error - and can keep waiting with arduino_task_status wait:true.', + }, + timeout_seconds: { + type: 'number', + description: + 'Max seconds to block when wait=true (default 60, clamp 5-600). Keep below your MCP client per-call timeout; a first build for a new core can take 2-3 minutes.', + }, }, }, annotations: { @@ -141,7 +151,7 @@ export const ARDUINO_TOOLS: ToolDefinition[] = [ { name: 'arduino_upload', description: - '[CAUTION] Compile and upload a sketch to an Arduino board. This OVERWRITES the firmware on the device. Returns a task ID for progress tracking via arduino_task_status.', + '[CAUTION] Compile and upload a sketch to an Arduino board. This OVERWRITES the firmware on the device. Recommended: pass wait:true to block until the flash finishes. Without wait it returns a task ID for progress tracking via arduino_task_status.', inputSchema: { type: 'object', properties: { @@ -164,6 +174,16 @@ export const ARDUINO_TOOLS: ToolDefinition[] = [ type: 'boolean', description: 'Verify upload after completion (default: true)', }, + wait: { + type: 'boolean', + description: + 'Block until compile+flash finishes or timeout_seconds elapses (default: false). A timeout returns current progress, not an error.', + }, + timeout_seconds: { + type: 'number', + description: + 'Max seconds to block when wait=true (default 60, clamp 5-600).', + }, }, }, annotations: { @@ -372,7 +392,7 @@ export const ARDUINO_TOOLS: ToolDefinition[] = [ { name: 'arduino_task_status', description: - 'Check status of async operations (compile, upload). Returns pending/running/completed/failed status, progress, and result.', + 'Check status of async operations (compile, upload). Returns pending/running/completed/failed status, progress, and result. Pass wait:true to block until the task finishes instead of polling.', inputSchema: { type: 'object', properties: { @@ -380,6 +400,16 @@ export const ARDUINO_TOOLS: ToolDefinition[] = [ type: 'string', description: 'Task ID returned from arduino_compile or arduino_upload', }, + wait: { + type: 'boolean', + description: + 'Block until the task reaches a terminal status or timeout_seconds elapses (default: false). A timeout returns current progress with timed_out:true - just call again to keep waiting.', + }, + timeout_seconds: { + type: 'number', + description: + 'Max seconds to block when wait=true (default 60, clamp 5-600).', + }, }, required: ['task_id'], }, diff --git a/arduino-mcp-extension/src/common/mcp-types.ts b/arduino-mcp-extension/src/common/mcp-types.ts index 06474d43086..c131e665334 100644 --- a/arduino-mcp-extension/src/common/mcp-types.ts +++ b/arduino-mcp-extension/src/common/mcp-types.ts @@ -21,6 +21,12 @@ export interface Task { error?: string; progress?: number; progressMessage?: string; + /** Date.now() when the task was created. */ + createdAt: number; + /** Date.now() when the runner started executing. */ + startedAt?: number; + /** Date.now() when the task reached a terminal status. */ + finishedAt?: number; } /** diff --git a/arduino-mcp-extension/src/node/mcp-server.ts b/arduino-mcp-extension/src/node/mcp-server.ts index 4a30f8daef5..836b76d150c 100644 --- a/arduino-mcp-extension/src/node/mcp-server.ts +++ b/arduino-mcp-extension/src/node/mcp-server.ts @@ -74,6 +74,23 @@ const DEFAULT_PORT = 3847; const MAX_TASKS = 100; const SKETCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; +// Bounds for the `wait`/`timeout_seconds` option on compile/upload/task_status. +// The default stays well under typical MCP client per-call timeouts; a timeout +// returns current progress (not an error), so callers can simply re-wait. +const TASK_WAIT_DEFAULT_SECONDS = 60; +const TASK_WAIT_MIN_SECONDS = 5; +const TASK_WAIT_MAX_SECONDS = 600; + +function clampTaskTimeout(value: unknown): number { + if (typeof value !== 'number' || !isFinite(value)) { + return TASK_WAIT_DEFAULT_SECONDS; + } + return Math.min( + TASK_WAIT_MAX_SECONDS, + Math.max(TASK_WAIT_MIN_SECONDS, value) + ); +} + interface StructuredBuildError { message: string; file?: string; @@ -116,6 +133,8 @@ export class ArduinoMCPServer { // Task management for async operations private readonly tasks = new Map(); + // Completion promises so `wait` callers can block on a running task. + private readonly taskPromises = new Map>(); private taskCounter = 0; // State tracking @@ -1057,11 +1076,15 @@ export class ArduinoMCPServer { fqbn: args.fqbn, verbose: args.verbose, }); - setImmediate(() => this.runCompileTask(task)); + this.launchTask(task, () => this.runCompileTask(task)); + if (args.wait) { + return this.waitForTask(task, clampTaskTimeout(args.timeout_seconds)); + } return { taskId: task.id, + status: task.status, message: - 'Compilation started. Use arduino_task_status to check progress and arduino_build_output for compiler output.', + 'Compilation started. Use arduino_task_status to check progress and arduino_build_output for compiler output. Tip: pass wait:true to block until it finishes.', }; } @@ -1075,11 +1098,15 @@ export class ArduinoMCPServer { port: args.port, verify: args.verify, }); - setImmediate(() => this.runUploadTask(task)); + this.launchTask(task, () => this.runUploadTask(task)); + if (args.wait) { + return this.waitForTask(task, clampTaskTimeout(args.timeout_seconds)); + } return { taskId: task.id, + status: task.status, message: - 'Upload started. This will OVERWRITE firmware on the device. Use arduino_task_status to check progress.', + 'Upload started. This will OVERWRITE firmware on the device. Use arduino_task_status to check progress. Tip: pass wait:true to block until it finishes.', }; } @@ -1602,7 +1629,14 @@ export class ArduinoMCPServer { if (!taskId) throw new Error('task_id is required'); const task = this.tasks.get(taskId); if (!task) throw new Error(`Task not found: ${taskId}`); + if ( + args.wait && + (task.status === 'pending' || task.status === 'running') + ) { + return this.waitForTask(task, clampTaskTimeout(args.timeout_seconds)); + } return { + taskId: task.id, status: task.status, result: task.result, error: task.error, @@ -1627,19 +1661,75 @@ export class ArduinoMCPServer { status: 'pending', tool, arguments: args, + createdAt: Date.now(), }; this.tasks.set(taskId, task); this.pruneTasks(); return task; } + /** + * Starts a task runner and keeps its completion promise so `wait` callers + * can block on it. The runners never reject (all paths are caught), but the + * catch guard keeps a future bug from becoming an unhandled rejection. + */ + private launchTask(task: Task, runner: () => Promise): void { + this.taskPromises.set( + task.id, + runner().catch(() => undefined) + ); + } + + /** + * Blocks until the task reaches a terminal status or the timeout elapses. + * A timeout is NOT an error: the caller gets the current status/progress and + * the taskId, and can keep waiting via arduino_task_status {wait:true}. + */ + private async waitForTask( + task: Task, + timeoutSeconds: number + ): Promise { + const completion = this.taskPromises.get(task.id) ?? Promise.resolve(); + let timer: NodeJS.Timeout | undefined; + const finished = await Promise.race([ + completion.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutSeconds * 1000); + }), + ]); + if (timer) clearTimeout(timer); + if (!finished) { + return { + taskId: task.id, + status: task.status, + progress: task.progress, + progressMessage: task.progressMessage, + timed_out: true, + hint: `Still running after ${timeoutSeconds}s. Call arduino_task_status with task_id "${task.id}" and wait:true to keep waiting.`, + }; + } + return { + taskId: task.id, + status: task.status, + result: task.result, + error: task.error, + progress: task.progress, + progressMessage: task.progressMessage, + }; + } + private pruneTasks(): void { if (this.tasks.size <= MAX_TASKS) { return; } for (const [id, task] of this.tasks) { - if (task.status === 'completed' || task.status === 'failed') { + if ( + task.status === 'completed' || + task.status === 'failed' || + task.status === 'cancelled' + ) { this.tasks.delete(id); + this.taskPromises.delete(id); } if (this.tasks.size <= MAX_TASKS) { return; @@ -1649,6 +1739,7 @@ export class ArduinoMCPServer { private async runCompileTask(task: Task): Promise { task.status = 'running'; + task.startedAt = Date.now(); task.progress = 0; task.progressMessage = 'Preparing compilation...'; @@ -1687,11 +1778,13 @@ export class ArduinoMCPServer { executableSectionsSize: summary?.executableSectionsSize, }; task.status = 'completed'; + task.finishedAt = Date.now(); task.progress = 100; task.progressMessage = 'Compilation complete'; this.recordBuild('compile', []); } catch (e) { task.status = 'failed'; + task.finishedAt = Date.now(); const errors = this.structuredErrorsOf(e); task.error = `Compilation failed: ${ e instanceof Error ? e.message : e @@ -1705,6 +1798,7 @@ export class ArduinoMCPServer { private async runUploadTask(task: Task): Promise { task.status = 'running'; + task.startedAt = Date.now(); task.progress = 0; task.progressMessage = 'Preparing upload...'; let compileFailed = false; @@ -1783,11 +1877,13 @@ export class ArduinoMCPServer { portAfterUpload: result.portAfterUpload, }; task.status = 'completed'; + task.finishedAt = Date.now(); task.progress = 100; task.progressMessage = 'Upload complete'; this.recordBuild('upload', []); } catch (e) { task.status = 'failed'; + task.finishedAt = Date.now(); const errors = this.structuredErrorsOf(e); task.error = `${ compileFailed ? 'Upload failed during compilation' : 'Upload failed'