From af898c99fe8de46a26150ecd160b44d6150224fa Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 23:47:38 +0000 Subject: [PATCH 1/2] feat(lineage): tell concurrent Cursor MCP calls apart by the arguments the pre-tool hook recorded (#424) --- .../424-cursor-mcp-correlation-arguments.md | 6 +++ docs/entry-conventions.md | 2 +- .../capabilities/cursor-2026-08-28.json | 5 ++- .../agent-bundle/src/mcp-server-runtime.ts | 11 +++-- packages/rsc-runtime/src/lineage/registry.ts | 34 ++++++++++++++-- packages/rsc-runtime/src/lineage/state.ts | 8 ++++ .../tests/lineage-registry.test.ts | 40 +++++++++++++++++++ 7 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 .changeset/424-cursor-mcp-correlation-arguments.md diff --git a/.changeset/424-cursor-mcp-correlation-arguments.md b/.changeset/424-cursor-mcp-correlation-arguments.md new file mode 100644 index 000000000..d6f0b5a5e --- /dev/null +++ b/.changeset/424-cursor-mcp-correlation-arguments.md @@ -0,0 +1,6 @@ +--- +"@agent-bundle/runtime": patch +"agent-bundle": patch +--- + +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:` 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) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 6929368a4..0a3b6bb6d 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -428,7 +428,7 @@ for every event by the id the payload carries. The observed host vocabulary | --- | --- | --- | --- | --- | | Claude | `agent_id`, else `session_id` | `session_id` | the agent whose `Agent`/`Task` `PreToolUse` is the newest unclaimed spawn | `_meta["claudecode/toolUseId"]` = the open `PreToolUse` `tool_use_id` | | Codex | `agent_id`, else `session_id` | `session_id` | the thread whose `spawn_agent` call is the newest unclaimed spawn | `_meta["x-codex-turn-metadata"]` carries `thread_id`, `parent_thread_id`, `session_id`, `turn_id` natively | -| Cursor | `conversation_id` | the bound root | `parent_conversation_id` on `subagentStart`; the child's fresh `conversation_id` is bound to the single pending start in the same workspace when it first speaks | the newest open `preToolUse` whose `tool_name` is `MCP:` | +| Cursor | `conversation_id` | the bound root | `parent_conversation_id` on `subagentStart`; the child's fresh `conversation_id` is bound to the single pending start in the same workspace when it first speaks | the newest open `preToolUse` whose `tool_name` is `MCP:`; when several conversations have that tool open, the one whose hook `tool_input` equals the call's `arguments` (identical arguments stay `id-not-resolvable`) | A Claude or Codex subagent is placed only when its spawning pre-tool hook (`Agent`/`Task`, `collaborationspawn_agent`) was observed, so projects that diff --git a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json index 47be8c194..5913a466c 100644 --- a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json +++ b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json @@ -336,7 +336,10 @@ }, "mcp-correlation": { "state": "degraded", - "reason": "2026-09-03: tools/call _meta carries only progressToken and the client name is cursor-vscode (#424); the generated server resolves the caller through the open preToolUse whose tool_name is MCP:, and refuses when open windows for that tool span several conversations." + "reason": "2026-09-03: tools/call _meta carries only progressToken and the client name is cursor-vscode (#424); the generated server resolves the caller through the open preToolUse whose tool_name is MCP:. When open windows for that tool span several conversations it narrows by the arguments: the preToolUse tool_input is the call's arguments verbatim (fixtures/host-lineage/cursor-3.18.25.ndjson rows 44/45, 53/54: MCP:probe {\"note\":\"subagent\"} / {\"note\":\"nested\"} arrived at the server as exactly those arguments), so a concurrent call with different arguments resolves (resolution: inferred, provenance derived) and identical arguments stay id-not-resolvable — never a guess by name.", + "evidence": [ + "2026-09-03 live capture on Cursor 3.18.25 (fixtures/host-lineage/cursor-3.18.25.ndjson): preToolUse MCP:probe tool_input {\"note\":\"subagent\"} (row 44) preceded a tools/call whose server-side record shows note: \"subagent\" and _meta { progressToken } only (row 45); the nested conversation repeated the pattern with {\"note\":\"nested\"} (rows 53/54)." + ] }, "cloud": { "state": "unavailable", diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index 223182151..45bfc0624 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -112,19 +112,22 @@ interface GeneratedRouteIdentity { /** * Lineage for one MCP tool call: Codex names it in `_meta`, Claude names the * pre-tool hook's `tool_use_id` in `_meta`, Cursor names nothing — so the - * registry falls back to the open `MCP:` pre-tool hook. Without a - * registry (a project with no event routes, or the in-memory proof level) the - * axis is honestly absent. + * registry falls back to the open `MCP:` pre-tool hook, told apart from + * a concurrent call in another conversation by the arguments the hook + * recorded. Without a registry (a project with no event routes, or the + * in-memory proof level) the axis is honestly absent. */ const toolCallLineage = async ( registry: AgentLineageRegistry | undefined, context: GeneratedRouteRequestContext, toolName: string, + input: unknown, clientName: string | undefined, fallbackHost: LineageHost | undefined, ): Promise> => { if (registry === undefined) return unavailable('not-provided'); return registry.resolveToolCall({ + arguments: input, host: lineageHostFromClient(clientName) ?? fallbackHost, meta: context.mcpReq._meta, toolName, @@ -314,7 +317,7 @@ export const registerGeneratedRoutes = ( route, input, context, - { clientName, lineage: await toolCallLineage(options.lineage, context, route.name, clientName, options.lineageHost) }, + { clientName, lineage: await toolCallLineage(options.lineage, context, route.name, input, clientName, options.lineageHost) }, ); return attachMcpStructuredContent(rendered.toolResult, rendered.result); }, options.afterRender)) as never); diff --git a/packages/rsc-runtime/src/lineage/registry.ts b/packages/rsc-runtime/src/lineage/registry.ts index bff15bcc8..d55dd6a54 100644 --- a/packages/rsc-runtime/src/lineage/registry.ts +++ b/packages/rsc-runtime/src/lineage/registry.ts @@ -38,6 +38,13 @@ export interface LineageObservation { } export interface LineageToolCallQuery { + /** + * The `tools/call` arguments. Without a conversation id in `_meta` (Cursor + * sends only `progressToken`, #424) they are the one payload fact shared + * with the pre-tool hook's `tool_input`, and narrow the open windows when + * several conversations have the same tool open at once. + */ + readonly arguments?: unknown; readonly host: LineageHost | undefined; /** The MCP request `_meta`, when the transport supplied one. */ readonly meta?: Readonly> | undefined; @@ -69,6 +76,18 @@ const nativeString = (native: Readonly>, key: string): s return typeof value === 'string' && value.trim() !== '' ? value : undefined; }; +/** + * Digest of a tool's arguments as the hosts deliver them: the hook's + * `tool_input` object and the MCP `arguments` object are the same value, so + * `undefined` and `{}` digest alike (both mean "no arguments"). Non-object + * values never match anything. + */ +const inputDigest = (input: unknown): string | undefined => { + const value = input === undefined ? {} : input; + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; + return createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex').slice(0, 16); +}; + /** * The hosts' own subagent-spawning tools, by exact native spelling (observed * 2026-09-03: Claude `Agent`, Codex `collaborationspawn_agent`, Cursor @@ -499,9 +518,11 @@ export const createAgentLineageRegistry = ( const carrierNode = carrier.conversation === undefined ? undefined : nodeFor(carrier.conversation); if (carrier.conversation !== undefined && carrierNode !== undefined && toolCallId !== undefined && toolName !== undefined) { if (event === 'tool/before') { + const digest = inputDigest(native['tool_input']); await dispatch('toolCallOpened', { conversation: carrier.conversation, ...(carrier.generation === undefined ? {} : { generation: carrier.generation }), + ...(digest === undefined ? {} : { inputDigest: digest }), openedAt: observedAt, root: carrierNode.root, ...(SPAWN_TOOLS[host](toolName) ? { spawn: true } : {}), @@ -541,6 +562,7 @@ export const createAgentLineageRegistry = ( } } const { host, meta, toolName } = query; + const argumentsDigest = inputDigest(query.arguments); if (host === undefined) return unavailable('id-not-resolvable'); if (host === 'codex') { const turn = meta?.['x-codex-turn-metadata']; @@ -588,13 +610,19 @@ export const createAgentLineageRegistry = ( // The open pre-tool hooks naming this tool: `MCP:` on Cursor, // `mcp____` on Codex, `mcp__plugin_

___` on // Claude. Several from one conversation share a lineage; several from - // different conversations cannot be told apart without `_meta`. + // different conversations are told apart only by the arguments the + // hook recorded — identical arguments stay ambiguous, never guessed. const matches = state.openCalls.filter((candidate) => candidate.toolName === `MCP:${toolName}` || candidate.toolName.endsWith(`__${toolName}`) || candidate.toolName === toolName); - if (new Set(matches.map((candidate) => candidate.conversation)).size > 1) return unavailable('id-not-resolvable'); - call = matches[matches.length - 1]; + const conversations = (candidates: readonly OpenToolCall[]): number => new Set(candidates.map((candidate) => candidate.conversation)).size; + let narrowed = matches; + if (conversations(matches) > 1 && argumentsDigest !== undefined) { + narrowed = matches.filter((candidate) => candidate.inputDigest === argumentsDigest); + } + if (conversations(narrowed) > 1) return unavailable('id-not-resolvable'); + call = narrowed[narrowed.length - 1]; } if (call === undefined) return unavailable('id-not-resolvable'); const node = nodeFor(call.conversation); diff --git a/packages/rsc-runtime/src/lineage/state.ts b/packages/rsc-runtime/src/lineage/state.ts index 7dee45284..e379b02ce 100644 --- a/packages/rsc-runtime/src/lineage/state.ts +++ b/packages/rsc-runtime/src/lineage/state.ts @@ -36,6 +36,14 @@ export const OpenToolCallSchema = z.object({ conversation: id, /** The carrier's turn-shaped id when the window opened (Cursor `generation_id`, Codex `turn_id`, Claude `prompt_id`). */ generation: id.optional(), + /** + * Digest of the pre-tool hook's `tool_input`. Every host delivers the MCP + * call's arguments there (observed 2026-09-03: Cursor `MCP:probe` + * `{"note":"subagent"}` arrived at the server as `note: "subagent"`), so a + * call whose `_meta` names no conversation can still be told apart from a + * concurrent call to the same tool with different arguments. + */ + inputDigest: id.optional(), openedAt: timestamp, /** The root the conversation belonged to when the window opened, so retirement finds it even after its node is pruned. */ root: id.optional(), diff --git a/packages/rsc-runtime/tests/lineage-registry.test.ts b/packages/rsc-runtime/tests/lineage-registry.test.ts index 800353cce..83b1ea821 100644 --- a/packages/rsc-runtime/tests/lineage-registry.test.ts +++ b/packages/rsc-runtime/tests/lineage-registry.test.ts @@ -585,6 +585,46 @@ describe('lineage registry ambiguity refusals (review round 4)', () => { await observe('tool/after', 'mb-close', { conversation_id: 'root-b', hook_event_name: 'postToolUse', tool_input: {}, tool_name: 'MCP:dump', tool_output: '{}', tool_use_id: 'mb' }); expect(await registry.resolveToolCall({ host: 'cursor', toolName: 'dump' })).toMatchObject({ value: { conversation: 'root-a' } }); }); + + it('Cursor 3.18.25: concurrent MCP: windows in several conversations are told apart by the arguments the pre-tool hook recorded (#424)', async () => { + // fixtures/host-lineage/cursor-3.18.25.ndjson rows 44/45 and 53/54: preToolUse `MCP:probe` carries + // tool_input {"note":"subagent"} / {"note":"nested"}, and the server received exactly those arguments + // while `_meta` carried only progressToken. Two conversations with the tool open at once are + // resolvable when their arguments differ, and stay refused when they do not. + const registry = createAgentLineageRegistry(); + const observe = (event: string, key: string, native: Record) => + registry.observe({ event, host: 'cursor', idempotencyKey: key, native }); + await observe('prompt/submit', 'a', { conversation_id: 'root-a', hook_event_name: 'beforeSubmitPrompt' }); + await observe('prompt/submit', 'b', { conversation_id: 'root-b', hook_event_name: 'beforeSubmitPrompt' }); + await observe('tool/before', 'pa', { conversation_id: 'root-a', hook_event_name: 'preToolUse', tool_input: { note: 'subagent' }, tool_name: 'MCP:probe', tool_use_id: 'pa' }); + 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' }); + + // Without arguments the two windows are indistinguishable, exactly as before. + expect(await registry.resolveToolCall({ host: 'cursor', toolName: 'probe' })).toEqual(unavailable('id-not-resolvable')); + // The arguments pick the window whose hook recorded them; key order does not matter, the digest is canonical. + expect(await registry.resolveToolCall({ arguments: { note: 'nested' }, host: 'cursor', toolName: 'probe' })).toMatchObject({ + source: 'derived', + value: { conversation: 'root-b', depth: 0, resolution: 'inferred' }, + }); + expect(await registry.resolveToolCall({ arguments: { note: 'subagent' }, host: 'cursor', toolName: 'probe' })).toMatchObject({ + value: { conversation: 'root-a' }, + }); + // Arguments no open window recorded resolve nothing: never a guess by name. + expect(await registry.resolveToolCall({ arguments: { note: 'other' }, host: 'cursor', toolName: 'probe' })).toEqual(unavailable('id-not-resolvable')); + + // Identical arguments in both conversations stay ambiguous. + await observe('tool/before', 'da', { conversation_id: 'root-a', hook_event_name: 'preToolUse', tool_input: {}, tool_name: 'MCP:dump', tool_use_id: 'da' }); + await observe('tool/before', 'db', { conversation_id: 'root-b', hook_event_name: 'preToolUse', tool_input: {}, tool_name: 'MCP:dump', tool_use_id: 'db' }); + expect(await registry.resolveToolCall({ arguments: {}, host: 'cursor', toolName: 'dump' })).toEqual(unavailable('id-not-resolvable')); + expect(await registry.resolveToolCall({ host: 'cursor', toolName: 'dump' })).toEqual(unavailable('id-not-resolvable')); + + // A single open conversation never needs the arguments to agree: `undefined` and `{}` are the same + // "no arguments", and a mismatch cannot make a lone window ambiguous. + await observe('tool/after', 'db-close', { conversation_id: 'root-b', hook_event_name: 'postToolUse', tool_input: {}, tool_name: 'MCP:dump', tool_output: '{}', tool_use_id: 'db' }); + expect(await registry.resolveToolCall({ arguments: undefined, host: 'cursor', toolName: 'dump' })).toMatchObject({ value: { conversation: 'root-a' } }); + expect(await registry.resolveToolCall({ arguments: { limit: 10 }, host: 'cursor', toolName: 'dump' })).toMatchObject({ value: { conversation: 'root-a' } }); + expect(registry.snapshot().openCalls.find((call) => call.toolCallId === 'pa')?.inputDigest).toBeDefined(); + }); }); describe('lineage registry retirement and cohorts (review round 5)', () => { From c09abbdccbde10f19d70e8367cc4951e699da047 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 00:03:05 +0000 Subject: [PATCH 2/2] fix(lineage): correlate by the raw wire arguments and keep undigested windows in contention (review) --- .../424-cursor-mcp-correlation-arguments.md | 2 +- .../agent-bundle/src/mcp-server-runtime.ts | 75 +++++++++++++++++-- .../tests/mcp-server-runtime.test.ts | 55 ++++++++++++++ packages/rsc-runtime/src/lineage/registry.ts | 20 +++-- .../tests/lineage-registry.test.ts | 20 +++++ 5 files changed, 156 insertions(+), 16 deletions(-) diff --git a/.changeset/424-cursor-mcp-correlation-arguments.md b/.changeset/424-cursor-mcp-correlation-arguments.md index d6f0b5a5e..227d3c714 100644 --- a/.changeset/424-cursor-mcp-correlation-arguments.md +++ b/.changeset/424-cursor-mcp-correlation-arguments.md @@ -3,4 +3,4 @@ "agent-bundle": patch --- -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:` 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) +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:` 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) diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index 45bfc0624..7079dbd29 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -16,7 +16,7 @@ */ import { Worker } from 'node:worker_threads'; -import { McpServer, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/server'; +import { McpServer, ProtocolError, ProtocolErrorCode, isJSONRPCRequest, type Transport } from '@modelcontextprotocol/server'; import { AgentRuntimeError, agent, @@ -84,6 +84,8 @@ export interface GeneratedRouteRequestContext { readonly mcpReq: { /** Request `_meta`: the progress token plus host-specific correlation keys (`claudecode/toolUseId`, `x-codex-turn-metadata`). */ readonly _meta?: { readonly progressToken?: McpProgressToken } & Readonly>; + /** The JSON-RPC request id, the key the raw `tools/call` arguments were captured under. */ + readonly id?: number | string; readonly notify?: (notification: { readonly method: 'notifications/progress'; readonly params: McpProgressNotificationParams; @@ -109,25 +111,79 @@ interface GeneratedRouteIdentity { readonly workspace: Observed; } +/** A captured `tools/call` `params.arguments` value; `value` is `undefined` when the call carried none. */ +export interface RawToolArguments { + readonly value: unknown; +} + +/** Raw `tools/call` arguments by request, consumed once by the tool callback that serves the request. */ +export interface RawToolArgumentsCapture { + take(requestId: number | string | undefined): RawToolArguments | undefined; +} + +const requestKey = (requestId: number | string): string => `${typeof requestId}:${String(requestId)}`; +/** Calls that never reach a registered tool (unknown tool, rejected params) are forgotten past this many. */ +const RAW_ARGUMENTS_RETENTION = 1024; + +/** + * Captures every `tools/call`'s arguments off the wire, before the SDK parses + * them against the route's input schema. Lineage correlates a Cursor call with + * its pre-tool hook by those raw arguments (the hook's `tool_input`); schema + * defaults would make two different calls look alike, so the parsed input is + * never what is compared. The capture wraps the transport's `onmessage` the + * moment the server connects and keeps one entry per request id until the + * tool callback takes it. + */ +const captureRawToolArguments = (server: McpServer): RawToolArgumentsCapture => { + const captured = new Map(); + const connect = server.connect.bind(server); + server.connect = async (transport: Transport): Promise => { + await connect(transport); + const inner = transport.onmessage; + transport.onmessage = (message, extra) => { + if (isJSONRPCRequest(message) && message.method === 'tools/call') { + const params = message.params; + const value = params !== undefined && typeof params === 'object' && params !== null && !Array.isArray(params) + ? (params as { readonly arguments?: unknown }).arguments + : undefined; + captured.set(requestKey(message.id), Object.freeze({ value })); + if (captured.size > RAW_ARGUMENTS_RETENTION) captured.delete(captured.keys().next().value!); + } + inner?.(message, extra); + }; + }; + return Object.freeze({ + take(requestId: number | string | undefined): RawToolArguments | undefined { + if (requestId === undefined) return undefined; + const key = requestKey(requestId); + const raw = captured.get(key); + captured.delete(key); + return raw; + }, + }); +}; + /** * Lineage for one MCP tool call: Codex names it in `_meta`, Claude names the * pre-tool hook's `tool_use_id` in `_meta`, Cursor names nothing — so the * registry falls back to the open `MCP:` pre-tool hook, told apart from - * a concurrent call in another conversation by the arguments the hook - * recorded. Without a registry (a project with no event routes, or the - * in-memory proof level) the axis is honestly absent. + * a concurrent call in another conversation by the raw arguments the hook + * recorded. A call whose raw arguments were not captured (a transport the + * server did not connect itself) is correlated by tool name alone, never by + * its schema-parsed input. Without a registry (a project with no event + * routes, or the in-memory proof level) the axis is honestly absent. */ const toolCallLineage = async ( registry: AgentLineageRegistry | undefined, context: GeneratedRouteRequestContext, toolName: string, - input: unknown, + rawArguments: RawToolArguments | undefined, clientName: string | undefined, fallbackHost: LineageHost | undefined, ): Promise> => { if (registry === undefined) return unavailable('not-provided'); return registry.resolveToolCall({ - arguments: input, + ...(rawArguments === undefined ? {} : { arguments: rawArguments.value }), host: lineageHostFromClient(clientName) ?? fallbackHost, meta: context.mcpReq._meta, toolName, @@ -291,6 +347,8 @@ export interface RegisterGeneratedRoutesOptions { readonly lineage?: AgentLineageRegistry; /** The artifact's host, used when the negotiated client name maps to none. */ readonly lineageHost?: LineageHost; + /** Raw `tools/call` arguments captured off the wire, for lineage correlation. */ + readonly rawArguments?: RawToolArgumentsCapture; } /** Registers the compiled MCP routes on a server, keyed by route kind. */ @@ -311,13 +369,14 @@ export const registerGeneratedRoutes = ( ...(outputSchema === undefined ? {} : { outputSchema }), } as never, (async (input: unknown, context: GeneratedRouteRequestContext) => settled(async () => { const clientName = server.server.getClientVersion()?.name; + const rawArguments = options.rawArguments?.take(context.mcpReq.id); const rendered = await renderGeneratedRoute( dispatcher, artifactEpoch, route, input, context, - { clientName, lineage: await toolCallLineage(options.lineage, context, route.name, input, clientName, options.lineageHost) }, + { clientName, lineage: await toolCallLineage(options.lineage, context, route.name, rawArguments, clientName, options.lineageHost) }, ); return attachMcpStructuredContent(rendered.toolResult, rendered.result); }, options.afterRender)) as never); @@ -840,7 +899,7 @@ export const createGeneratedRouteMcpServer = async ( : await startEventRuntime(options.events, dispatcher, options.host, afterRender, options.lineage); registerGeneratedRoutes(server, options.routes, dispatcher, options.artifactEpoch, { ...(afterRender === undefined ? {} : { afterRender }), - ...(options.lineage === undefined ? {} : { lineage: options.lineage }), + ...(options.lineage === undefined ? {} : { lineage: options.lineage, rawArguments: captureRawToolArguments(server) }), ...(options.events === undefined || lineageHostFor(options.events.target) === undefined ? {} : { lineageHost: lineageHostFor(options.events.target) }), diff --git a/packages/agent-bundle/tests/mcp-server-runtime.test.ts b/packages/agent-bundle/tests/mcp-server-runtime.test.ts index e9f465ef9..480dc34aa 100644 --- a/packages/agent-bundle/tests/mcp-server-runtime.test.ts +++ b/packages/agent-bundle/tests/mcp-server-runtime.test.ts @@ -1,3 +1,5 @@ +import { unavailable } from '@agent-bundle/runtime'; +import type { AgentLineageRegistry, LineageToolCallQuery } from '@agent-bundle/runtime/lineage'; import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; import { describe, expect, it } from '@rstest/core'; import { z } from 'zod'; @@ -75,6 +77,59 @@ const stubs = (options: { return { host, notices, order }; }; +describe('generated server lineage correlation', () => { + it('hands the registry the raw tools/call arguments, not the schema-parsed input with defaults applied', async () => { + // Cursor's hook records the arguments as sent (`tool_input`); a schema default + // would make `{}` and `{ label: 'probe' }` parse alike and misattribute the + // omitted-argument call, so the capture must read the wire, not the callback input. + const queries: LineageToolCallQuery[] = []; + const lineage: AgentLineageRegistry = { + observe: async () => unavailable('id-not-resolvable'), + resolveToolCall: async (query) => { + queries.push(query); + return unavailable('id-not-resolvable'); + }, + snapshot: () => ({ nodes: {}, openCalls: [], pendingChildren: [], pendingSpawns: [], seenStarts: [] }), + }; + const { host } = stubs(); + const server = await createGeneratedRouteMcpServer({ + artifactEpoch: 'epoch', + host, + lineage, + plugin: { name: 'raw-arguments', version: '0.0.0' }, + routes: { + 'mcp/raw/tools/probe': { + config: {}, + id: 'mcp/raw/tools/probe', + kind: 'tool', + module: { + default: () => undefined, + inputSchema: z.object({ label: z.string().default('probe') }).strict(), + resultSchema: z.object({ ok: z.boolean() }).strict(), + }, + name: 'probe', + }, + }, + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'cursor-vscode', version: '1.0.0' }); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + try { + await client.callTool({ arguments: {}, name: 'probe' }, { signal: AbortSignal.timeout(5_000) }); + await client.callTool({ arguments: { label: 'probe' }, name: 'probe' }, { signal: AbortSignal.timeout(5_000) }); + await client.callTool({ arguments: { label: 'other' }, name: 'probe' }, { signal: AbortSignal.timeout(5_000) }); + expect(queries.map((query) => [Object.hasOwn(query, 'arguments'), query.arguments, query.host, query.toolName])).toEqual([ + [true, {}, 'cursor', 'probe'], + [true, { label: 'probe' }, 'cursor', 'probe'], + [true, { label: 'other' }, 'cursor', 'probe'], + ]); + } finally { + await client.close(); + await server.close(); + } + }); +}); + describe('generated server render completion', () => { it('answers a completed render while the inbox observation is still pending on another connection', async () => { // The signaller renews a hold for as long as a notification write takes, diff --git a/packages/rsc-runtime/src/lineage/registry.ts b/packages/rsc-runtime/src/lineage/registry.ts index d55dd6a54..bd6ba1e1b 100644 --- a/packages/rsc-runtime/src/lineage/registry.ts +++ b/packages/rsc-runtime/src/lineage/registry.ts @@ -39,10 +39,13 @@ export interface LineageObservation { export interface LineageToolCallQuery { /** - * The `tools/call` arguments. Without a conversation id in `_meta` (Cursor - * sends only `progressToken`, #424) they are the one payload fact shared - * with the pre-tool hook's `tool_input`, and narrow the open windows when - * several conversations have the same tool open at once. + * The raw `tools/call` arguments, exactly as the client sent them. Without a + * conversation id in `_meta` (Cursor sends only `progressToken`, #424) they + * are the one payload fact shared with the pre-tool hook's `tool_input`, and + * narrow the open windows when several conversations have the same tool open + * at once. Omit the property (rather than passing `undefined`) when the raw + * arguments are unknown: an absent call argument is `undefined` and digests + * like `{}`, an absent property disables the narrowing. */ readonly arguments?: unknown; readonly host: LineageHost | undefined; @@ -562,7 +565,7 @@ export const createAgentLineageRegistry = ( } } const { host, meta, toolName } = query; - const argumentsDigest = inputDigest(query.arguments); + const argumentsDigest = Object.hasOwn(query, 'arguments') ? inputDigest(query.arguments) : undefined; if (host === undefined) return unavailable('id-not-resolvable'); if (host === 'codex') { const turn = meta?.['x-codex-turn-metadata']; @@ -611,7 +614,10 @@ export const createAgentLineageRegistry = ( // `mcp____` on Codex, `mcp__plugin_

___` on // Claude. Several from one conversation share a lineage; several from // different conversations are told apart only by the arguments the - // hook recorded — identical arguments stay ambiguous, never guessed. + // hook recorded — identical arguments stay ambiguous, never guessed, + // and a window opened before digests were recorded (or from a hook + // whose `tool_input` was not an object) could own any call, so it is + // never excluded. const matches = state.openCalls.filter((candidate) => candidate.toolName === `MCP:${toolName}` || candidate.toolName.endsWith(`__${toolName}`) @@ -619,7 +625,7 @@ export const createAgentLineageRegistry = ( const conversations = (candidates: readonly OpenToolCall[]): number => new Set(candidates.map((candidate) => candidate.conversation)).size; let narrowed = matches; if (conversations(matches) > 1 && argumentsDigest !== undefined) { - narrowed = matches.filter((candidate) => candidate.inputDigest === argumentsDigest); + narrowed = matches.filter((candidate) => candidate.inputDigest === undefined || candidate.inputDigest === argumentsDigest); } if (conversations(narrowed) > 1) return unavailable('id-not-resolvable'); call = narrowed[narrowed.length - 1]; diff --git a/packages/rsc-runtime/tests/lineage-registry.test.ts b/packages/rsc-runtime/tests/lineage-registry.test.ts index 83b1ea821..b02e7cee9 100644 --- a/packages/rsc-runtime/tests/lineage-registry.test.ts +++ b/packages/rsc-runtime/tests/lineage-registry.test.ts @@ -625,6 +625,26 @@ describe('lineage registry ambiguity refusals (review round 4)', () => { expect(await registry.resolveToolCall({ arguments: { limit: 10 }, host: 'cursor', toolName: 'dump' })).toMatchObject({ value: { conversation: 'root-a' } }); expect(registry.snapshot().openCalls.find((call) => call.toolCallId === 'pa')?.inputDigest).toBeDefined(); }); + + it('keeps a window without a recorded digest in contention, so an upgraded journal never attributes by elimination', async () => { + // A durable registry upgraded mid-session still holds pre-upgrade windows with no + // inputDigest (the field is optional for v1 journals). Such a window could own any + // call for its tool, so it is never filtered out: with a digested competitor in + // another conversation the call stays id-not-resolvable. + const registry = createAgentLineageRegistry(); + const observe = (event: string, key: string, native: Record) => + registry.observe({ event, host: 'cursor', idempotencyKey: key, native }); + await observe('prompt/submit', 'a', { conversation_id: 'root-a', hook_event_name: 'beforeSubmitPrompt' }); + await observe('prompt/submit', 'b', { conversation_id: 'root-b', hook_event_name: 'beforeSubmitPrompt' }); + // A pre-upgrade window: the hook carried no object tool_input, so no digest was recorded. + await observe('tool/before', 'legacy', { conversation_id: 'root-a', hook_event_name: 'preToolUse', tool_input: 'opaque', tool_name: 'MCP:probe', tool_use_id: 'legacy' }); + expect(registry.snapshot().openCalls.find((call) => call.toolCallId === 'legacy')?.inputDigest).toBeUndefined(); + 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' }); + expect(await registry.resolveToolCall({ arguments: { note: 'nested' }, host: 'cursor', toolName: 'probe' })).toEqual(unavailable('id-not-resolvable')); + // Once the digested competitor closes, the legacy window resolves alone, whatever the arguments. + 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' }); + expect(await registry.resolveToolCall({ arguments: { note: 'nested' }, host: 'cursor', toolName: 'probe' })).toMatchObject({ value: { conversation: 'root-a' } }); + }); }); describe('lineage registry retirement and cohorts (review round 5)', () => {