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
11 changes: 10 additions & 1 deletion arduino-mcp-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand Down Expand Up @@ -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`.

Expand Down
3 changes: 2 additions & 1 deletion arduino-mcp-extension/src/common/mcp-tool-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
36 changes: 33 additions & 3 deletions arduino-mcp-extension/src/common/mcp-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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: {
Expand All @@ -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: {
Expand All @@ -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: {
Expand Down Expand Up @@ -372,14 +392,24 @@ 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: {
task_id: {
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'],
},
Expand Down
6 changes: 6 additions & 0 deletions arduino-mcp-extension/src/common/mcp-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
106 changes: 101 additions & 5 deletions arduino-mcp-extension/src/node/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -116,6 +133,8 @@ export class ArduinoMCPServer {

// Task management for async operations
private readonly tasks = new Map<string, Task>();
// Completion promises so `wait` callers can block on a running task.
private readonly taskPromises = new Map<string, Promise<void>>();
private taskCounter = 0;

// State tracking
Expand Down Expand Up @@ -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.',
};
}

Expand All @@ -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.',
};
}

Expand Down Expand Up @@ -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,
Expand All @@ -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>): 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<unknown> {
const completion = this.taskPromises.get(task.id) ?? Promise.resolve();
let timer: NodeJS.Timeout | undefined;
const finished = await Promise.race([
completion.then(() => true),
new Promise<boolean>((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;
Expand All @@ -1649,6 +1739,7 @@ export class ArduinoMCPServer {

private async runCompileTask(task: Task): Promise<void> {
task.status = 'running';
task.startedAt = Date.now();
task.progress = 0;
task.progressMessage = 'Preparing compilation...';

Expand Down Expand Up @@ -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
Expand All @@ -1705,6 +1798,7 @@ export class ArduinoMCPServer {

private async runUploadTask(task: Task): Promise<void> {
task.status = 'running';
task.startedAt = Date.now();
task.progress = 0;
task.progressMessage = 'Preparing upload...';
let compileFailed = false;
Expand Down Expand Up @@ -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'
Expand Down