Skip to content
This repository was archived by the owner on Aug 3, 2026. It is now read-only.

Commit e0283ba

Browse files
committed
feat: explicit error for failing tool matching
1 parent 9c6dd66 commit e0283ba

3 files changed

Lines changed: 179 additions & 5 deletions

File tree

‎src/proxy/bridge-non-streaming.ts‎

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@ import {
1515
computeUsage,
1616
createConnectFrameParser,
1717
createThinkingTagFilter,
18+
type McpToolCallUpdateInfo,
1819
parseConnectEndStream,
1920
processServerMessage,
2021
scheduleBridgeEnd,
2122
} from "./stream-dispatch";
2223
import { createBridgeCloseController } from "./bridge-close-controller";
2324

24-
const MCP_TOOL_BATCH_WINDOW_MS = 75;
25+
const MCP_TOOL_BATCH_WINDOW_MS = 150;
26+
const MCP_TOOL_METADATA_GRACE_MS = 1_500;
2527

2628
interface CollectedResponse {
2729
text: string;
@@ -88,6 +90,8 @@ async function collectFullResponse(
8890
let endStreamError: Error | null = null;
8991
const pendingToolCalls: OpenAIToolCall[] = [];
9092
let toolCallEndTimer: NodeJS.Timeout | undefined;
93+
const announcedToolCallIds = new Set<string>();
94+
let toolCallMetadataDeadlineMs = 0;
9195

9296
const { bridge, heartbeatTimer } = await startBridge(
9397
accessToken,
@@ -99,10 +103,44 @@ async function collectFullResponse(
99103
clearTimeout(toolCallEndTimer);
100104
toolCallEndTimer = undefined;
101105
};
106+
const getMissingAnnouncedToolCallIds = () => {
107+
const pendingExecIds = new Set(pendingToolCalls.map((call) => call.id));
108+
return [...announcedToolCallIds].filter(
109+
(toolCallId) => !pendingExecIds.has(toolCallId),
110+
);
111+
};
102112
const scheduleToolCallBridgeEnd = () => {
103113
stopToolCallEndTimer();
104114
toolCallEndTimer = setTimeout(
105-
() => scheduleBridgeEnd(bridge),
115+
() => {
116+
const missingAnnouncedToolCallIds = getMissingAnnouncedToolCallIds();
117+
if (
118+
missingAnnouncedToolCallIds.length > 0 &&
119+
Date.now() < toolCallMetadataDeadlineMs
120+
) {
121+
scheduleToolCallBridgeEnd();
122+
return;
123+
}
124+
125+
if (missingAnnouncedToolCallIds.length > 0) {
126+
endStreamError = new Error(
127+
`Cursor announced MCP tool calls without matching exec metadata: ${missingAnnouncedToolCallIds.join(", ")}`,
128+
);
129+
logPluginError(
130+
"Aborting non-streaming Cursor response because announced MCP tool calls never received exec metadata",
131+
{
132+
modelId,
133+
convKey,
134+
pendingExecToolCallIds: pendingToolCalls.map((call) => call.id),
135+
missingAnnouncedToolCallIds,
136+
},
137+
);
138+
scheduleBridgeEnd(bridge);
139+
return;
140+
}
141+
142+
scheduleBridgeEnd(bridge);
143+
},
106144
MCP_TOOL_BATCH_WINDOW_MS,
107145
);
108146
};
@@ -135,6 +173,7 @@ async function collectFullResponse(
135173
fullText += content;
136174
},
137175
(exec) => {
176+
announcedToolCallIds.add(exec.toolCallId);
138177
const toolCall = {
139178
id: exec.toolCallId,
140179
type: "function" as const,
@@ -151,8 +190,21 @@ async function collectFullResponse(
151190
} else {
152191
pendingToolCalls.push(toolCall);
153192
}
193+
toolCallMetadataDeadlineMs =
194+
Date.now() + MCP_TOOL_METADATA_GRACE_MS;
154195
scheduleToolCallBridgeEnd();
155196
},
197+
(info: McpToolCallUpdateInfo) => {
198+
if (info.updateCase === "toolCallCompleted") {
199+
announcedToolCallIds.delete(info.toolCallId);
200+
} else {
201+
announcedToolCallIds.add(info.toolCallId);
202+
}
203+
204+
if (pendingToolCalls.length > 0) {
205+
scheduleToolCallBridgeEnd();
206+
}
207+
},
156208
(checkpointBytes) => {
157209
updateConversationCheckpoint(convKey, checkpointBytes);
158210
bridgeCloseController.noteCheckpoint();

‎src/proxy/bridge-streaming.ts‎

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,16 @@ import {
3939
computeUsage,
4040
createConnectFrameParser,
4141
createThinkingTagFilter,
42+
type McpToolCallUpdateInfo,
4243
parseConnectEndStream,
4344
processServerMessage,
4445
scheduleBridgeEnd,
4546
} from "./stream-dispatch";
4647
import { createBridgeCloseController } from "./bridge-close-controller";
4748

4849
const SSE_KEEPALIVE_INTERVAL_MS = 15_000;
49-
const MCP_TOOL_BATCH_WINDOW_MS = 75;
50+
const MCP_TOOL_BATCH_WINDOW_MS = 150;
51+
const MCP_TOOL_METADATA_GRACE_MS = 1_500;
5052

5153
function createBridgeStreamResponse(
5254
bridge: CursorSession,
@@ -91,6 +93,8 @@ function createBridgeStreamResponse(
9193
let mcpExecReceived = false;
9294
let endStreamError: Error | null = null;
9395
let toolCallsFlushed = false;
96+
const announcedToolCallIds = new Set<string>();
97+
let toolCallMetadataDeadlineMs = 0;
9498

9599
const sendSSE = (data: object) => {
96100
if (closed) return;
@@ -145,12 +149,51 @@ function createBridgeStreamResponse(
145149
usage: { prompt_tokens, completion_tokens, total_tokens },
146150
};
147151
};
152+
const getMissingAnnouncedToolCallIds = () => {
153+
const pendingExecIds = new Set(
154+
state.pendingExecs.map((exec) => exec.toolCallId),
155+
);
156+
return [...announcedToolCallIds].filter(
157+
(toolCallId) => !pendingExecIds.has(toolCallId),
158+
);
159+
};
148160
const publishPendingToolCalls = () => {
149161
if (closed || toolCallsFlushed || state.pendingExecs.length === 0) return;
150162

163+
const missingAnnouncedToolCallIds = getMissingAnnouncedToolCallIds();
164+
if (
165+
missingAnnouncedToolCallIds.length > 0 &&
166+
Date.now() < toolCallMetadataDeadlineMs
167+
) {
168+
schedulePendingToolCallPublish();
169+
return;
170+
}
171+
151172
toolCallsFlushed = true;
152173
stopToolCallFlushTimer();
153174

175+
if (missingAnnouncedToolCallIds.length > 0) {
176+
logPluginError(
177+
"Aborting Cursor stream because announced MCP tool calls never received exec metadata",
178+
{
179+
modelId,
180+
bridgeKey,
181+
convKey,
182+
pendingExecToolCallIds: state.pendingExecs.map(
183+
(exec) => exec.toolCallId,
184+
),
185+
missingAnnouncedToolCallIds,
186+
},
187+
);
188+
clearInterval(heartbeatTimer);
189+
bridge.end();
190+
failStream(
191+
`Cursor announced MCP tool calls without matching exec metadata: ${missingAnnouncedToolCallIds.join(", ")}`,
192+
"cursor_mcp_tool_mismatch",
193+
);
194+
return;
195+
}
196+
154197
const flushed = tagFilter.flush();
155198
if (flushed.reasoning)
156199
sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
@@ -183,7 +226,7 @@ function createBridgeStreamResponse(
183226
blobStore,
184227
cloudRule,
185228
mcpTools,
186-
pendingExecs: [...state.pendingExecs],
229+
pendingExecs: state.pendingExecs,
187230
modelId,
188231
metadata: {
189232
...metadata,
@@ -233,6 +276,7 @@ function createBridgeStreamResponse(
233276
}
234277
},
235278
(exec) => {
279+
announcedToolCallIds.add(exec.toolCallId);
236280
const existingIndex = state.pendingExecs.findIndex(
237281
(candidate) => candidate.toolCallId === exec.toolCallId,
238282
);
@@ -242,9 +286,22 @@ function createBridgeStreamResponse(
242286
state.pendingExecs.push(exec);
243287
}
244288
mcpExecReceived = true;
289+
toolCallMetadataDeadlineMs =
290+
Date.now() + MCP_TOOL_METADATA_GRACE_MS;
245291

246292
schedulePendingToolCallPublish();
247293
},
294+
(info: McpToolCallUpdateInfo) => {
295+
if (info.updateCase === "toolCallCompleted") {
296+
announcedToolCallIds.delete(info.toolCallId);
297+
} else {
298+
announcedToolCallIds.add(info.toolCallId);
299+
}
300+
301+
if (mcpExecReceived) {
302+
schedulePendingToolCallPublish();
303+
}
304+
},
248305
(checkpointBytes) => {
249306
updateConversationCheckpoint(convKey, checkpointBytes);
250307
bridgeCloseController.noteCheckpoint();
@@ -479,7 +536,14 @@ export async function handleToolResultResume(
479536
.filter(Boolean)
480537
.join("\n\n"),
481538
};
539+
const pendingToolCallIds = new Set(pendingExecs.map((exec) => exec.toolCallId));
482540
const toolResultIds = new Set(toolResults.map((result) => result.toolCallId));
541+
const missingToolResultIds = pendingExecs
542+
.map((exec) => exec.toolCallId)
543+
.filter((toolCallId) => !toolResultIds.has(toolCallId));
544+
const unexpectedToolResultIds = toolResults
545+
.map((result) => result.toolCallId)
546+
.filter((toolCallId) => !pendingToolCallIds.has(toolCallId));
483547
const matchedPendingExecs = pendingExecs.filter((exec) =>
484548
toolResultIds.has(exec.toolCallId),
485549
);
@@ -519,6 +583,35 @@ export async function handleToolResultResume(
519583
);
520584
}
521585

586+
if (missingToolResultIds.length > 0 || unexpectedToolResultIds.length > 0) {
587+
logPluginError("Aborting Cursor tool-result resume because tool-call ids did not match", {
588+
bridgeKey,
589+
convKey,
590+
modelId,
591+
pendingToolCallIds: [...pendingToolCallIds],
592+
toolResultIds: [...toolResultIds],
593+
missingToolResultIds,
594+
unexpectedToolResultIds,
595+
});
596+
clearInterval(heartbeatTimer);
597+
bridge.end();
598+
return new Response(
599+
JSON.stringify({
600+
error: {
601+
message:
602+
"Tool-result ids did not match the active Cursor MCP tool calls. Aborting instead of resuming a potentially stuck session.",
603+
type: "invalid_request_error",
604+
code: "cursor_tool_result_mismatch",
605+
details: {
606+
missingToolResultIds,
607+
unexpectedToolResultIds,
608+
},
609+
},
610+
}),
611+
{ status: 400, headers: { "Content-Type": "application/json" } },
612+
);
613+
}
614+
522615
for (const exec of matchedPendingExecs) {
523616
const result = toolResults.find(
524617
(toolResult) => toolResult.toolCallId === exec.toolCallId,

‎src/proxy/stream-dispatch.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ export interface UnsupportedServerMessageInfo {
7676
detail?: string;
7777
}
7878

79+
export interface McpToolCallUpdateInfo {
80+
updateCase: "partialToolCall" | "toolCallStarted" | "toolCallCompleted";
81+
toolCallId: string;
82+
modelCallId?: string;
83+
toolName?: string;
84+
}
85+
7986
export function parseConnectEndStream(data: Uint8Array): Error | null {
8087
try {
8188
const payload = JSON.parse(new TextDecoder().decode(data));
@@ -220,6 +227,7 @@ export function processServerMessage(
220227
state: StreamState,
221228
onText: (text: string, isThinking?: boolean) => void,
222229
onMcpExec: (exec: PendingExec) => void,
230+
onMcpToolCallUpdate?: (info: McpToolCallUpdateInfo) => void,
223231
onCheckpoint?: (checkpointBytes: Uint8Array) => void,
224232
onTurnEnded?: () => void,
225233
onUnsupportedMessage?: (info: UnsupportedServerMessageInfo) => void,
@@ -232,6 +240,7 @@ export function processServerMessage(
232240
msg.message.value,
233241
state,
234242
onText,
243+
onMcpToolCallUpdate,
235244
onTurnEnded,
236245
onUnsupportedMessage,
237246
);
@@ -278,6 +287,7 @@ function handleInteractionUpdate(
278287
update: any,
279288
state: StreamState,
280289
onText: (text: string, isThinking?: boolean) => void,
290+
onMcpToolCallUpdate?: (info: McpToolCallUpdateInfo) => void,
281291
onTurnEnded?: () => void,
282292
onUnsupportedMessage?: (info: UnsupportedServerMessageInfo) => void,
283293
): void {
@@ -293,7 +303,26 @@ function handleInteractionUpdate(
293303
callId: update.message?.value?.callId,
294304
modelCallId: update.message?.value?.modelCallId,
295305
toolCase: update.message?.value?.toolCall?.tool?.case,
296-
});
306+
});
307+
}
308+
309+
if (
310+
(updateCase === "partialToolCall" ||
311+
updateCase === "toolCallStarted" ||
312+
updateCase === "toolCallCompleted") &&
313+
update.message?.value?.toolCall?.tool?.case === "mcpToolCall"
314+
) {
315+
const toolValue = update.message.value;
316+
const toolArgs = toolValue?.toolCall?.tool?.value?.args;
317+
const toolCallId = toolArgs?.toolCallId || toolValue.callId;
318+
if (toolCallId) {
319+
onMcpToolCallUpdate?.({
320+
updateCase,
321+
toolCallId,
322+
modelCallId: toolValue.modelCallId,
323+
toolName: toolArgs?.toolName || toolArgs?.name,
324+
});
325+
}
297326
}
298327

299328
if (updateCase === "textDelta") {

0 commit comments

Comments
 (0)