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
6 changes: 6 additions & 0 deletions .changeset/424-cursor-mcp-correlation-arguments.md
Original file line number Diff line number Diff line change
@@ -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:<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)
2 changes: 1 addition & 1 deletion docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<tool>` |
| 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:<tool>`; 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:<tool>, 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:<tool>. 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",
Expand Down
74 changes: 68 additions & 6 deletions packages/agent-bundle/src/mcp-server-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Record<string, unknown>>;
/** 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;
Expand All @@ -109,22 +111,79 @@ interface GeneratedRouteIdentity {
readonly workspace: Observed<AgentWorkspaceIdentity>;
}

/** 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<string, RawToolArguments>();
const connect = server.connect.bind(server);
server.connect = async (transport: Transport): Promise<void> => {
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:<tool>` 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:<tool>` pre-tool hook, told apart from
* 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,
rawArguments: RawToolArguments | undefined,
clientName: string | undefined,
fallbackHost: LineageHost | undefined,
): Promise<Observed<AgentLineage>> => {
if (registry === undefined) return unavailable<AgentLineage>('not-provided');
return registry.resolveToolCall({
...(rawArguments === undefined ? {} : { arguments: rawArguments.value }),
host: lineageHostFromClient(clientName) ?? fallbackHost,
meta: context.mcpReq._meta,
toolName,
Expand Down Expand Up @@ -288,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. */
Expand All @@ -308,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, 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);
Expand Down Expand Up @@ -837,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) }),
Expand Down
55 changes: 55 additions & 0 deletions packages/agent-bundle/tests/mcp-server-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 37 additions & 3 deletions packages/rsc-runtime/src/lineage/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ export interface LineageObservation {
}

export interface LineageToolCallQuery {
/**
* 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;
/** The MCP request `_meta`, when the transport supplied one. */
readonly meta?: Readonly<Record<string, unknown>> | undefined;
Expand Down Expand Up @@ -69,6 +79,18 @@ const nativeString = (native: Readonly<Record<string, unknown>>, 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
Expand Down Expand Up @@ -499,9 +521,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 } : {}),
Expand Down Expand Up @@ -541,6 +565,7 @@ export const createAgentLineageRegistry = (
}
}
const { host, meta, toolName } = query;
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'];
Expand Down Expand Up @@ -588,13 +613,22 @@ export const createAgentLineageRegistry = (
// The open pre-tool hooks naming this tool: `MCP:<tool>` on Cursor,
// `mcp__<server>__<tool>` on Codex, `mcp__plugin_<p>_<s>__<tool>` 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,
// 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}`)
|| 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 === undefined || candidate.inputDigest === argumentsDigest);
}
Comment on lines +627 to +629

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep legacy undigested windows ambiguous

After upgrading a durable registry that still contains an open pre-upgrade window, that window has no inputDigest because the field remains optional for v1 compatibility. If a new window for the same tool opens in another conversation with identical arguments, this filter discards the legacy contender and treats the new conversation as uniquely matched, even though either window could own the request. Any competing window without a digest must preserve id-not-resolvable rather than allowing attribution.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c09abbd. A candidate window without an inputDigest (pre-upgrade journal, or a hook whose tool_input was not an object) is never filtered out: narrowed keeps every undigested window alongside the digest matches, so a digested competitor in another conversation leaves the call id-not-resolvable. Regression test: lineage-registry.test.ts 'keeps a window without a recorded digest in contention' — legacy root-a window plus digested root-b → refused; once root-b closes the legacy window resolves alone.

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);
Expand Down
8 changes: 8 additions & 0 deletions packages/rsc-runtime/src/lineage/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading
Loading