Skip to content

Commit c09abbd

Browse files
fix(lineage): correlate by the raw wire arguments and keep undigested windows in contention (review)
1 parent af898c9 commit c09abbd

5 files changed

Lines changed: 156 additions & 16 deletions

File tree

‎.changeset/424-cursor-mcp-correlation-arguments.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
"agent-bundle": patch
44
---
55

6-
Resolve `request.lineage` for concurrent Cursor MCP calls by their arguments. Cursor's `tools/call` `_meta` names no conversation, so a generated MCP server correlated a call only through the open `MCP:<tool>` pre-tool hook and reported `id-not-resolvable` whenever several conversations had the same tool open. The pre-tool hook's `tool_input` is the call's arguments verbatim, so the lineage registry now records their digest on each open window (`inputDigest`) and the generated server passes the call's arguments to `resolveToolCall`; a concurrent call with different arguments resolves (`resolution: inferred`, provenance `derived`), identical arguments still refuse, and a single open conversation is unaffected. (#483)
6+
Resolve `request.lineage` for concurrent Cursor MCP calls by their arguments. Cursor's `tools/call` `_meta` names no conversation, so a generated MCP server correlated a call only through the open `MCP:<tool>` pre-tool hook and reported `id-not-resolvable` whenever several conversations had the same tool open. The pre-tool hook's `tool_input` is the call's arguments verbatim, so the lineage registry now records their digest on each open window (`inputDigest`) and the generated server passes the call's raw wire arguments (captured before schema parsing, so input defaults never make two calls look alike) to `resolveToolCall`; a concurrent call with different arguments resolves (`resolution: inferred`, provenance `derived`), identical arguments still refuse, a window recorded without a digest stays in contention, and a single open conversation is unaffected. (#483)

‎packages/agent-bundle/src/mcp-server-runtime.ts‎

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717
import { Worker } from 'node:worker_threads';
1818

19-
import { McpServer, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/server';
19+
import { McpServer, ProtocolError, ProtocolErrorCode, isJSONRPCRequest, type Transport } from '@modelcontextprotocol/server';
2020
import {
2121
AgentRuntimeError,
2222
agent,
@@ -84,6 +84,8 @@ export interface GeneratedRouteRequestContext {
8484
readonly mcpReq: {
8585
/** Request `_meta`: the progress token plus host-specific correlation keys (`claudecode/toolUseId`, `x-codex-turn-metadata`). */
8686
readonly _meta?: { readonly progressToken?: McpProgressToken } & Readonly<Record<string, unknown>>;
87+
/** The JSON-RPC request id, the key the raw `tools/call` arguments were captured under. */
88+
readonly id?: number | string;
8789
readonly notify?: (notification: {
8890
readonly method: 'notifications/progress';
8991
readonly params: McpProgressNotificationParams;
@@ -109,25 +111,79 @@ interface GeneratedRouteIdentity {
109111
readonly workspace: Observed<AgentWorkspaceIdentity>;
110112
}
111113

114+
/** A captured `tools/call` `params.arguments` value; `value` is `undefined` when the call carried none. */
115+
export interface RawToolArguments {
116+
readonly value: unknown;
117+
}
118+
119+
/** Raw `tools/call` arguments by request, consumed once by the tool callback that serves the request. */
120+
export interface RawToolArgumentsCapture {
121+
take(requestId: number | string | undefined): RawToolArguments | undefined;
122+
}
123+
124+
const requestKey = (requestId: number | string): string => `${typeof requestId}:${String(requestId)}`;
125+
/** Calls that never reach a registered tool (unknown tool, rejected params) are forgotten past this many. */
126+
const RAW_ARGUMENTS_RETENTION = 1024;
127+
128+
/**
129+
* Captures every `tools/call`'s arguments off the wire, before the SDK parses
130+
* them against the route's input schema. Lineage correlates a Cursor call with
131+
* its pre-tool hook by those raw arguments (the hook's `tool_input`); schema
132+
* defaults would make two different calls look alike, so the parsed input is
133+
* never what is compared. The capture wraps the transport's `onmessage` the
134+
* moment the server connects and keeps one entry per request id until the
135+
* tool callback takes it.
136+
*/
137+
const captureRawToolArguments = (server: McpServer): RawToolArgumentsCapture => {
138+
const captured = new Map<string, RawToolArguments>();
139+
const connect = server.connect.bind(server);
140+
server.connect = async (transport: Transport): Promise<void> => {
141+
await connect(transport);
142+
const inner = transport.onmessage;
143+
transport.onmessage = (message, extra) => {
144+
if (isJSONRPCRequest(message) && message.method === 'tools/call') {
145+
const params = message.params;
146+
const value = params !== undefined && typeof params === 'object' && params !== null && !Array.isArray(params)
147+
? (params as { readonly arguments?: unknown }).arguments
148+
: undefined;
149+
captured.set(requestKey(message.id), Object.freeze({ value }));
150+
if (captured.size > RAW_ARGUMENTS_RETENTION) captured.delete(captured.keys().next().value!);
151+
}
152+
inner?.(message, extra);
153+
};
154+
};
155+
return Object.freeze({
156+
take(requestId: number | string | undefined): RawToolArguments | undefined {
157+
if (requestId === undefined) return undefined;
158+
const key = requestKey(requestId);
159+
const raw = captured.get(key);
160+
captured.delete(key);
161+
return raw;
162+
},
163+
});
164+
};
165+
112166
/**
113167
* Lineage for one MCP tool call: Codex names it in `_meta`, Claude names the
114168
* pre-tool hook's `tool_use_id` in `_meta`, Cursor names nothing — so the
115169
* registry falls back to the open `MCP:<tool>` pre-tool hook, told apart from
116-
* a concurrent call in another conversation by the arguments the hook
117-
* recorded. Without a registry (a project with no event routes, or the
118-
* in-memory proof level) the axis is honestly absent.
170+
* a concurrent call in another conversation by the raw arguments the hook
171+
* recorded. A call whose raw arguments were not captured (a transport the
172+
* server did not connect itself) is correlated by tool name alone, never by
173+
* its schema-parsed input. Without a registry (a project with no event
174+
* routes, or the in-memory proof level) the axis is honestly absent.
119175
*/
120176
const toolCallLineage = async (
121177
registry: AgentLineageRegistry | undefined,
122178
context: GeneratedRouteRequestContext,
123179
toolName: string,
124-
input: unknown,
180+
rawArguments: RawToolArguments | undefined,
125181
clientName: string | undefined,
126182
fallbackHost: LineageHost | undefined,
127183
): Promise<Observed<AgentLineage>> => {
128184
if (registry === undefined) return unavailable<AgentLineage>('not-provided');
129185
return registry.resolveToolCall({
130-
arguments: input,
186+
...(rawArguments === undefined ? {} : { arguments: rawArguments.value }),
131187
host: lineageHostFromClient(clientName) ?? fallbackHost,
132188
meta: context.mcpReq._meta,
133189
toolName,
@@ -291,6 +347,8 @@ export interface RegisterGeneratedRoutesOptions {
291347
readonly lineage?: AgentLineageRegistry;
292348
/** The artifact's host, used when the negotiated client name maps to none. */
293349
readonly lineageHost?: LineageHost;
350+
/** Raw `tools/call` arguments captured off the wire, for lineage correlation. */
351+
readonly rawArguments?: RawToolArgumentsCapture;
294352
}
295353

296354
/** Registers the compiled MCP routes on a server, keyed by route kind. */
@@ -311,13 +369,14 @@ export const registerGeneratedRoutes = (
311369
...(outputSchema === undefined ? {} : { outputSchema }),
312370
} as never, (async (input: unknown, context: GeneratedRouteRequestContext) => settled(async () => {
313371
const clientName = server.server.getClientVersion()?.name;
372+
const rawArguments = options.rawArguments?.take(context.mcpReq.id);
314373
const rendered = await renderGeneratedRoute(
315374
dispatcher,
316375
artifactEpoch,
317376
route,
318377
input,
319378
context,
320-
{ clientName, lineage: await toolCallLineage(options.lineage, context, route.name, input, clientName, options.lineageHost) },
379+
{ clientName, lineage: await toolCallLineage(options.lineage, context, route.name, rawArguments, clientName, options.lineageHost) },
321380
);
322381
return attachMcpStructuredContent(rendered.toolResult, rendered.result);
323382
}, options.afterRender)) as never);
@@ -840,7 +899,7 @@ export const createGeneratedRouteMcpServer = async (
840899
: await startEventRuntime(options.events, dispatcher, options.host, afterRender, options.lineage);
841900
registerGeneratedRoutes(server, options.routes, dispatcher, options.artifactEpoch, {
842901
...(afterRender === undefined ? {} : { afterRender }),
843-
...(options.lineage === undefined ? {} : { lineage: options.lineage }),
902+
...(options.lineage === undefined ? {} : { lineage: options.lineage, rawArguments: captureRawToolArguments(server) }),
844903
...(options.events === undefined || lineageHostFor(options.events.target) === undefined
845904
? {}
846905
: { lineageHost: lineageHostFor(options.events.target) }),

‎packages/agent-bundle/tests/mcp-server-runtime.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { unavailable } from '@agent-bundle/runtime';
2+
import type { AgentLineageRegistry, LineageToolCallQuery } from '@agent-bundle/runtime/lineage';
13
import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
24
import { describe, expect, it } from '@rstest/core';
35
import { z } from 'zod';
@@ -75,6 +77,59 @@ const stubs = (options: {
7577
return { host, notices, order };
7678
};
7779

80+
describe('generated server lineage correlation', () => {
81+
it('hands the registry the raw tools/call arguments, not the schema-parsed input with defaults applied', async () => {
82+
// Cursor's hook records the arguments as sent (`tool_input`); a schema default
83+
// would make `{}` and `{ label: 'probe' }` parse alike and misattribute the
84+
// omitted-argument call, so the capture must read the wire, not the callback input.
85+
const queries: LineageToolCallQuery[] = [];
86+
const lineage: AgentLineageRegistry = {
87+
observe: async () => unavailable('id-not-resolvable'),
88+
resolveToolCall: async (query) => {
89+
queries.push(query);
90+
return unavailable('id-not-resolvable');
91+
},
92+
snapshot: () => ({ nodes: {}, openCalls: [], pendingChildren: [], pendingSpawns: [], seenStarts: [] }),
93+
};
94+
const { host } = stubs();
95+
const server = await createGeneratedRouteMcpServer({
96+
artifactEpoch: 'epoch',
97+
host,
98+
lineage,
99+
plugin: { name: 'raw-arguments', version: '0.0.0' },
100+
routes: {
101+
'mcp/raw/tools/probe': {
102+
config: {},
103+
id: 'mcp/raw/tools/probe',
104+
kind: 'tool',
105+
module: {
106+
default: () => undefined,
107+
inputSchema: z.object({ label: z.string().default('probe') }).strict(),
108+
resultSchema: z.object({ ok: z.boolean() }).strict(),
109+
},
110+
name: 'probe',
111+
},
112+
},
113+
});
114+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
115+
const client = new Client({ name: 'cursor-vscode', version: '1.0.0' });
116+
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
117+
try {
118+
await client.callTool({ arguments: {}, name: 'probe' }, { signal: AbortSignal.timeout(5_000) });
119+
await client.callTool({ arguments: { label: 'probe' }, name: 'probe' }, { signal: AbortSignal.timeout(5_000) });
120+
await client.callTool({ arguments: { label: 'other' }, name: 'probe' }, { signal: AbortSignal.timeout(5_000) });
121+
expect(queries.map((query) => [Object.hasOwn(query, 'arguments'), query.arguments, query.host, query.toolName])).toEqual([
122+
[true, {}, 'cursor', 'probe'],
123+
[true, { label: 'probe' }, 'cursor', 'probe'],
124+
[true, { label: 'other' }, 'cursor', 'probe'],
125+
]);
126+
} finally {
127+
await client.close();
128+
await server.close();
129+
}
130+
});
131+
});
132+
78133
describe('generated server render completion', () => {
79134
it('answers a completed render while the inbox observation is still pending on another connection', async () => {
80135
// The signaller renews a hold for as long as a notification write takes,

‎packages/rsc-runtime/src/lineage/registry.ts‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,13 @@ export interface LineageObservation {
3939

4040
export interface LineageToolCallQuery {
4141
/**
42-
* The `tools/call` arguments. Without a conversation id in `_meta` (Cursor
43-
* sends only `progressToken`, #424) they are the one payload fact shared
44-
* with the pre-tool hook's `tool_input`, and narrow the open windows when
45-
* several conversations have the same tool open at once.
42+
* The raw `tools/call` arguments, exactly as the client sent them. Without a
43+
* conversation id in `_meta` (Cursor sends only `progressToken`, #424) they
44+
* are the one payload fact shared with the pre-tool hook's `tool_input`, and
45+
* narrow the open windows when several conversations have the same tool open
46+
* at once. Omit the property (rather than passing `undefined`) when the raw
47+
* arguments are unknown: an absent call argument is `undefined` and digests
48+
* like `{}`, an absent property disables the narrowing.
4649
*/
4750
readonly arguments?: unknown;
4851
readonly host: LineageHost | undefined;
@@ -562,7 +565,7 @@ export const createAgentLineageRegistry = (
562565
}
563566
}
564567
const { host, meta, toolName } = query;
565-
const argumentsDigest = inputDigest(query.arguments);
568+
const argumentsDigest = Object.hasOwn(query, 'arguments') ? inputDigest(query.arguments) : undefined;
566569
if (host === undefined) return unavailable('id-not-resolvable');
567570
if (host === 'codex') {
568571
const turn = meta?.['x-codex-turn-metadata'];
@@ -611,15 +614,18 @@ export const createAgentLineageRegistry = (
611614
// `mcp__<server>__<tool>` on Codex, `mcp__plugin_<p>_<s>__<tool>` on
612615
// Claude. Several from one conversation share a lineage; several from
613616
// different conversations are told apart only by the arguments the
614-
// hook recorded — identical arguments stay ambiguous, never guessed.
617+
// hook recorded — identical arguments stay ambiguous, never guessed,
618+
// and a window opened before digests were recorded (or from a hook
619+
// whose `tool_input` was not an object) could own any call, so it is
620+
// never excluded.
615621
const matches = state.openCalls.filter((candidate) =>
616622
candidate.toolName === `MCP:${toolName}`
617623
|| candidate.toolName.endsWith(`__${toolName}`)
618624
|| candidate.toolName === toolName);
619625
const conversations = (candidates: readonly OpenToolCall[]): number => new Set(candidates.map((candidate) => candidate.conversation)).size;
620626
let narrowed = matches;
621627
if (conversations(matches) > 1 && argumentsDigest !== undefined) {
622-
narrowed = matches.filter((candidate) => candidate.inputDigest === argumentsDigest);
628+
narrowed = matches.filter((candidate) => candidate.inputDigest === undefined || candidate.inputDigest === argumentsDigest);
623629
}
624630
if (conversations(narrowed) > 1) return unavailable('id-not-resolvable');
625631
call = narrowed[narrowed.length - 1];

‎packages/rsc-runtime/tests/lineage-registry.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,26 @@ describe('lineage registry ambiguity refusals (review round 4)', () => {
625625
expect(await registry.resolveToolCall({ arguments: { limit: 10 }, host: 'cursor', toolName: 'dump' })).toMatchObject({ value: { conversation: 'root-a' } });
626626
expect(registry.snapshot().openCalls.find((call) => call.toolCallId === 'pa')?.inputDigest).toBeDefined();
627627
});
628+
629+
it('keeps a window without a recorded digest in contention, so an upgraded journal never attributes by elimination', async () => {
630+
// A durable registry upgraded mid-session still holds pre-upgrade windows with no
631+
// inputDigest (the field is optional for v1 journals). Such a window could own any
632+
// call for its tool, so it is never filtered out: with a digested competitor in
633+
// another conversation the call stays id-not-resolvable.
634+
const registry = createAgentLineageRegistry();
635+
const observe = (event: string, key: string, native: Record<string, unknown>) =>
636+
registry.observe({ event, host: 'cursor', idempotencyKey: key, native });
637+
await observe('prompt/submit', 'a', { conversation_id: 'root-a', hook_event_name: 'beforeSubmitPrompt' });
638+
await observe('prompt/submit', 'b', { conversation_id: 'root-b', hook_event_name: 'beforeSubmitPrompt' });
639+
// A pre-upgrade window: the hook carried no object tool_input, so no digest was recorded.
640+
await observe('tool/before', 'legacy', { conversation_id: 'root-a', hook_event_name: 'preToolUse', tool_input: 'opaque', tool_name: 'MCP:probe', tool_use_id: 'legacy' });
641+
expect(registry.snapshot().openCalls.find((call) => call.toolCallId === 'legacy')?.inputDigest).toBeUndefined();
642+
await observe('tool/before', 'pb', { conversation_id: 'root-b', hook_event_name: 'preToolUse', tool_input: { note: 'nested' }, tool_name: 'MCP:probe', tool_use_id: 'pb' });
643+
expect(await registry.resolveToolCall({ arguments: { note: 'nested' }, host: 'cursor', toolName: 'probe' })).toEqual(unavailable('id-not-resolvable'));
644+
// Once the digested competitor closes, the legacy window resolves alone, whatever the arguments.
645+
await observe('tool/after', 'pb-close', { conversation_id: 'root-b', hook_event_name: 'postToolUse', tool_input: { note: 'nested' }, tool_name: 'MCP:probe', tool_output: '{}', tool_use_id: 'pb' });
646+
expect(await registry.resolveToolCall({ arguments: { note: 'nested' }, host: 'cursor', toolName: 'probe' })).toMatchObject({ value: { conversation: 'root-a' } });
647+
});
628648
});
629649

630650
describe('lineage registry retirement and cohorts (review round 5)', () => {

0 commit comments

Comments
 (0)