Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
49 changes: 46 additions & 3 deletions src/remote-device/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ export interface MCPDeviceOptions {
persistSession?: boolean;
}

/**
* How many recently-handled call ids to remember for duplicate-delivery
* suppression. The two transports deliver a call within MILLISECONDS of each
* other, so this only has to outlive that window — 100 ids is several minutes
* of even the heaviest agent traffic, and costs ~10 KB on the user's machine
* (the device process, not the shared server).
*/
const SEEN_CALL_IDS_MAX = 100;

export class MCPDevice {
private baseServerUrl: string;
private remoteChannel: RemoteChannel;
Expand All @@ -21,6 +30,8 @@ export class MCPDevice {
private configPath: string;
private persistSession: boolean;
private desktop: DesktopCommanderIntegration;
/** Call ids already handled by THIS process (insertion-ordered, bounded). */
private seenCallIds: Set<string> = new Set();

constructor(options: MCPDeviceOptions = {}) {
this.baseServerUrl = process.env.MCP_SERVER_URL || 'https://mcp.desktopcommander.app';
Expand Down Expand Up @@ -259,6 +270,16 @@ export class MCPDevice {

// Methods moved to RemoteChannel

/** Record a handled call id, evicting the oldest once the cap is reached. */
private rememberCallId(callId: string) {
this.seenCallIds.add(callId);
if (this.seenCallIds.size > SEEN_CALL_IDS_MAX) {
// Sets iterate in insertion order — drop the oldest entry.
const oldest = this.seenCallIds.values().next().value;
if (oldest !== undefined) this.seenCallIds.delete(oldest);
}
}

async handleNewToolCall(payload: any) {
const toolCall = payload.new;
// Expect toolCall to include a device_id field used to route calls to this device instance.
Expand All @@ -274,9 +295,28 @@ export class MCPDevice {

console.log(`🔧 Received tool call ${call_id}: ${tool_name} ${JSON.stringify(tool_args)} metadata: ${JSON.stringify(metadata)}`);

// LOCAL claim first — this is the authoritative guard against executing
// a call twice. During the transition both transports deliver every call
// to THIS SAME PROCESS, so an in-memory check is sufficient and, unlike
// the DB claim below, cannot fail open: a transient REST error made
// markCallExecuting return true for both deliveries, which could run a
// side-effecting command twice (found in review, 2026-07-24).
if (this.seenCallIds.has(call_id)) {
console.debug('[DEBUG] Duplicate delivery for call already handled here, skipping:', call_id);
return;
}
this.rememberCallId(call_id);

try {
// Update call status to executing
await this.remoteChannel.markCallExecuting(call_id);
// DB claim second — keeps the row state machine honest, gives
// cross-restart/cross-process protection, and is observable. It may
// fail open (returns true on a transient write error); the local
// guard above is what makes execution exactly-once.
const claimed = await this.remoteChannel.markCallExecuting(call_id);
if (!claimed) {
// markCallExecuting already logged the duplicate-delivery skip.
return;
}

let result;

Expand Down Expand Up @@ -309,13 +349,16 @@ export class MCPDevice {

console.log(`✅ Tool call ${tool_name} completed:\r\n ${JSON.stringify(result)}`);

// Update database with result
// Update database with result, THEN ring the doorbell — the server
// fetches the row by id on the doorbell, so the write must land first.
await this.remoteChannel.updateCallResult(call_id, 'completed', result);
await this.remoteChannel.notifyResult(call_id);

} catch (error: any) {
console.error(`❌ Tool call ${tool_name} failed:`, error.message);
await captureRemote('remote_device_tool_call_failed', { error, tool_name });
await this.remoteChannel.updateCallResult(call_id, 'failed', null, error.message);
await this.remoteChannel.notifyResult(call_id);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
}

Expand Down
Loading
Loading