Skip to content

Commit 86c68af

Browse files
authored
Stamp client identity on MCP execution spans (#1621)
1 parent 6dff891 commit 86c68af

6 files changed

Lines changed: 263 additions & 4 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@executor-js/host-mcp": patch
3+
"@executor-js/cloudflare": patch
4+
---
5+
6+
**MCP execution spans now carry the client identity (`mcp.client.*`)**
7+
8+
The `clientInfo` a client self-reports at `initialize` (or in a modern request's `_meta`) previously existed only on the initialize request itself, which has no session id yet, so execution telemetry could not be segmented by client. Execute, execute-action, and resume spans (and their descendants) now carry `mcp.client.name` / `mcp.client.version` / `mcp.client.title` alongside the existing session join keys. Cloudflare session Durable Objects persist the reported identity in session meta, so attribution survives cold restores; it feeds telemetry only, never behavior.

apps/cloud/src/mcp/session-durable-object.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,10 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD
379379
// the negotiated apps support comes back from storage instead.
380380
restoredAppsEnabled: sessionMeta.appsEnabled ?? false,
381381
onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled),
382+
// Same restore contract for the client identity that keys the
383+
// `mcp.client.*` span attribution on execution spans.
384+
...(sessionMeta.clientInfo ? { restoredClientInfo: sessionMeta.clientInfo } : {}),
385+
onClientInfoChange: (clientInfo) => self.persistClientInfo(clientInfo),
382386
appsEnabled: false,
383387
sessionful: true,
384388
requestStateSigningKey: self.modernRequestStateSigningKey(),

packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,13 @@ import {
2424
import {
2525
appsEnabledForClientCapabilities,
2626
clientCapabilitiesFromRequestBody,
27+
clientInfoFromRequestBody,
2728
mcpRequestStateBindingFromBody,
2829
PAUSED_APPROVAL_TIMEOUT_MS,
2930
formatMcpExecutionOutcome,
3031
mcpRequestStatePrincipal,
3132
requestBodyFromRequest,
33+
type McpClientInfo,
3234
type PausedExecutionHooks,
3335
type ResumeFallbackOutcome,
3436
} from "@executor-js/host-mcp/tool-server";
@@ -147,6 +149,14 @@ export interface SessionMeta {
147149
* unknown, which behaves as disabled until the next `initialize`.
148150
*/
149151
readonly appsEnabled?: boolean;
152+
/**
153+
* The client identity (`clientInfo`) self-reported at `initialize` or in a
154+
* modern request's `_meta`. Persisted for the same reason as
155+
* {@link appsEnabled}: a cold-restored server never sees an `initialize`,
156+
* and without this the execution spans' `mcp.client.*` attribution vanishes
157+
* mid-conversation. Telemetry/display only, never behavior.
158+
*/
159+
readonly clientInfo?: McpClientInfo;
150160
/** Creation time of this session, retained across isolate eviction. */
151161
readonly createdAtMs?: number;
152162
}
@@ -164,6 +174,8 @@ export interface ModernMcpServerRequestOptions {
164174
readonly requestStateSigningKey: Uint8Array | string;
165175
readonly requestStatePrincipal: string;
166176
readonly requestStateBinding?: string;
177+
/** Client identity for span attribution: this request's `_meta`, else the session's persisted copy. */
178+
readonly restoredClientInfo?: McpClientInfo;
167179
}
168180

169181
/** Long-lived DO execution runtime shared by per-request MCP servers. */
@@ -515,6 +527,34 @@ export abstract class McpAgentSessionDOBase<
515527
);
516528
}
517529

530+
/**
531+
* Persist the client identity self-reported at `initialize` (or in a modern
532+
* request's `_meta`), so a cold restore keeps span attribution. Subclasses
533+
* hand this to `buildMcpServer` as `onClientInfoChange`; the modern request
534+
* path calls it directly. Same no-op-before-meta contract as
535+
* {@link persistAppsEnabled}.
536+
*/
537+
protected persistClientInfo(clientInfo: McpClientInfo): Effect.Effect<void> {
538+
const self = this;
539+
return Effect.gen(function* () {
540+
const stored = yield* self.loadSessionMeta();
541+
if (
542+
!stored ||
543+
(stored.clientInfo?.name === clientInfo.name &&
544+
stored.clientInfo?.version === clientInfo.version &&
545+
stored.clientInfo?.title === clientInfo.title)
546+
) {
547+
return;
548+
}
549+
yield* Effect.promise(() => self.saveSessionMeta({ ...stored, clientInfo }));
550+
}).pipe(
551+
Effect.withSpan("mcp.session.persist_client_info", {
552+
attributes: { "mcp.client.name": clientInfo.name },
553+
}),
554+
Effect.ignoreCause({ log: false }),
555+
);
556+
}
557+
518558
private async markActivity(now = Date.now()): Promise<void> {
519559
this.lastActivityMs = now;
520560
const key =
@@ -606,6 +646,7 @@ export abstract class McpAgentSessionDOBase<
606646
...resolved,
607647
...(token.webOrigin ? { webOrigin: token.webOrigin } : {}),
608648
appsEnabled: stored?.appsEnabled ?? false,
649+
...(stored?.clientInfo ? { clientInfo: stored.clientInfo } : {}),
609650
createdAtMs: stored?.createdAtMs ?? Date.now(),
610651
};
611652
yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe(
@@ -816,6 +857,7 @@ export abstract class McpAgentSessionDOBase<
816857
const parsedBody = self.modernRequestBodies.get(request);
817858
const propagation = self.modernRequestPropagation.get(request);
818859
const capabilities = clientCapabilitiesFromRequestBody(parsedBody);
860+
const clientInfo = clientInfoFromRequestBody(parsedBody) ?? sessionMeta.clientInfo;
819861
return Effect.runPromise(
820862
Effect.gen(function* () {
821863
const requestStatePrincipal = mcpRequestStatePrincipal({
@@ -834,6 +876,7 @@ export abstract class McpAgentSessionDOBase<
834876
requestStateSigningKey: self.modernRequestStateSigningKey(),
835877
requestStatePrincipal,
836878
...(requestStateBinding === null ? {} : { requestStateBinding }),
879+
...(clientInfo === undefined ? {} : { restoredClientInfo: clientInfo }),
837880
});
838881
}).pipe(
839882
(effect) => self.withTelemetry(effect, propagation),
@@ -1282,6 +1325,17 @@ export abstract class McpAgentSessionDOBase<
12821325
});
12831326
}
12841327

1328+
// Modern clients self-report identity per request (`_meta` clientInfo) or
1329+
// at `initialize`; persist it so meta-less requests and cold restores keep
1330+
// their span attribution. Best-effort by construction (persistClientInfo
1331+
// swallows failures) and a storage no-op unless the identity changed.
1332+
const reportedClientInfo = clientInfoFromRequestBody(parsedBody);
1333+
if (reportedClientInfo) {
1334+
await Effect.runPromise(
1335+
this.withTelemetry(this.persistClientInfo(reportedClientInfo), props.propagation),
1336+
);
1337+
}
1338+
12851339
this.modernRequestBodies.set(request, parsedBody);
12861340
this.modernRequestPropagation.set(request, props.propagation);
12871341
this.modernRunningRequestCount += 1;

packages/hosts/mcp/src/tool-server-core.ts

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,30 @@ type SharedMcpServerConfig = {
196196
* restore. Best-effort: failures are swallowed and never affect the session.
197197
*/
198198
readonly onAppsEnabledChange?: (appsEnabled: boolean) => Effect.Effect<void>;
199+
/**
200+
* The client identity self-reported at a previous `initialize` (or in a
201+
* modern request's `_meta`), restored for the same reason as
202+
* {@link restoredAppsEnabled}. Feeds only the `mcp.client.*` span
203+
* attributes on execution spans, never behavior or security decisions.
204+
*/
205+
readonly restoredClientInfo?: McpClientInfo;
206+
/**
207+
* Called when `initialize` reports the client identity, so the host can
208+
* persist it for {@link restoredClientInfo} on a later cold restore.
209+
* Best-effort: failures are swallowed and never affect the session.
210+
*/
211+
readonly onClientInfoChange?: (clientInfo: McpClientInfo) => Effect.Effect<void>;
212+
};
213+
214+
/**
215+
* Client software identity as self-reported over MCP (`clientInfo` at
216+
* `initialize`, `_meta` on modern requests). Display and telemetry vocabulary
217+
* only: the spec forbids relying on it for behavior or security.
218+
*/
219+
export type McpClientInfo = {
220+
readonly name: string;
221+
readonly version?: string;
222+
readonly title?: string;
199223
};
200224

201225
/**
@@ -340,6 +364,8 @@ export type ExecutorMcpAssembly<Server, RequestContext extends McpRequestJoinKey
340364
readonly server: Server;
341365
readonly initialAppsEnabled: boolean;
342366
readonly getClientCapabilities: () => unknown | null;
367+
/** The live `initialize`-reported client identity, when the SDK has one. */
368+
readonly getClientInfo: () => McpClientInfo | null;
343369
readonly getElicitationSupport: () => { readonly form: boolean; readonly url: boolean };
344370
readonly getUiCapability: () => { readonly mimeTypes?: readonly string[] } | undefined;
345371
readonly onInitialized: (callback: () => void) => void;
@@ -782,6 +808,21 @@ const joinKeyAttributes = (joinKeys: McpRequestJoinKeys): Record<string, unknown
782808
"mcp.request.session_id": joinKeys.sessionId ?? "",
783809
});
784810

811+
// Client identity uses the same `mcp.client.*` vocabulary as the cloud
812+
// worker's `initialize` fingerprint (`annotateMcpRequest`), so execution spans
813+
// segment by client directly instead of through a session join that `initialize`
814+
// (which has no session id yet) cannot satisfy. Absent means no key at all, not
815+
// an empty string, when no `initialize` was seen and nothing was restored:
816+
// unknown is real state here, unlike the always-present session key above.
817+
const clientInfoAttributes = (clientInfo: McpClientInfo | null): Record<string, unknown> =>
818+
clientInfo === null
819+
? {}
820+
: {
821+
"mcp.client.name": clientInfo.name,
822+
...(clientInfo.version !== undefined ? { "mcp.client.version": clientInfo.version } : {}),
823+
...(clientInfo.title !== undefined ? { "mcp.client.title": clientInfo.title } : {}),
824+
};
825+
785826
const startMarker = (name: string, attributes: Record<string, unknown>): Effect.Effect<void> =>
786827
Effect.void.pipe(Effect.withSpan(name, { attributes }));
787828

@@ -1110,6 +1151,15 @@ export const buildExecutorMcpTools = <
11101151
);
11111152
const server = assembly.server;
11121153

1154+
// Seeded from the host's persisted copy; a live `initialize` on this
1155+
// instance replaces it via `syncClientInfo` below. Read lazily at each
1156+
// tool call so spans always carry the newest identity.
1157+
let clientInfo: McpClientInfo | null = config.restoredClientInfo ?? null;
1158+
const requestSpanAttributes = (joinKeys: McpRequestJoinKeys): Record<string, unknown> => ({
1159+
...joinKeyAttributes(joinKeys),
1160+
...clientInfoAttributes(clientInfo),
1161+
});
1162+
11131163
const executeWithNativeElicitation = (
11141164
code: string,
11151165
extra: RequestContext,
@@ -1179,7 +1229,7 @@ export const buildExecutorMcpTools = <
11791229
"mcp.execute.code_length": code.length,
11801230
},
11811231
}),
1182-
Effect.annotateSpans(joinKeyAttributes(extra)),
1232+
Effect.annotateSpans(requestSpanAttributes(extra)),
11831233
);
11841234

11851235
/** What the caller could bind an unresolved role to. Best effort: the
@@ -1340,7 +1390,7 @@ export const buildExecutorMcpTools = <
13401390
"mcp.execute.execution_id": executionId,
13411391
},
13421392
}),
1343-
Effect.annotateSpans(joinKeyAttributes(extra)),
1393+
Effect.annotateSpans(requestSpanAttributes(extra)),
13441394
);
13451395

13461396
const requireUserResumeApproval = (executionId: string): Effect.Effect<McpToolResult> =>
@@ -1419,7 +1469,7 @@ export const buildExecutorMcpTools = <
14191469
"mcp.execute.execution_id": executionId,
14201470
},
14211471
}),
1422-
Effect.annotateSpans(joinKeyAttributes(extra)),
1472+
Effect.annotateSpans(requestSpanAttributes(extra)),
14231473
);
14241474

14251475
// --- tools ---
@@ -2169,9 +2219,35 @@ export const buildExecutorMcpTools = <
21692219
});
21702220
};
21712221

2222+
// Client identity arrives with the same `initialize` that carries the
2223+
// capabilities above. An absent live value (cold restore, construction
2224+
// time) is not evidence the client changed, so the restored value stands,
2225+
// the same asymmetry `syncToolAvailability` documents for capabilities.
2226+
const syncClientInfo = () => {
2227+
const live = assembly.getClientInfo();
2228+
if (live === null) return;
2229+
const changed =
2230+
live.name !== clientInfo?.name ||
2231+
live.version !== clientInfo?.version ||
2232+
live.title !== clientInfo?.title;
2233+
if (!changed) return;
2234+
clientInfo = live;
2235+
const onClientInfoChange = config.onClientInfoChange;
2236+
if (onClientInfoChange) {
2237+
// oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: `oninitialized` is a sync SDK hook; persistence is fire-and-forget and its failure must not fail the session
2238+
void Effect.runPromiseWith(context)(
2239+
onClientInfoChange(live).pipe(Effect.ignoreCause({ log: false })),
2240+
);
2241+
}
2242+
};
2243+
21722244
yield* Effect.sync(() => {
21732245
syncToolAvailability();
2174-
assembly.onInitialized(syncToolAvailability);
2246+
syncClientInfo();
2247+
assembly.onInitialized(() => {
2248+
syncToolAvailability();
2249+
syncClientInfo();
2250+
});
21752251
}).pipe(Effect.withSpan("mcp.host.sync_tool_availability"));
21762252

21772253
return server;

packages/hosts/mcp/src/tool-server.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution";
1717

1818
import {
1919
buildMcpServer,
20+
clientInfoFromRequestBody,
2021
formatMcpExecutionOutcome,
2122
type ExecutorMcpServerConfig,
23+
type McpClientInfo,
2224
} from "./tool-server";
2325

2426
// ---------------------------------------------------------------------------
@@ -71,6 +73,7 @@ type TestServerConfig<E extends Cause.YieldableError> = Pick<
7173
| "pausedExecutionHooks"
7274
| "pausedExecutionLeaseMs"
7375
| "resumeFallback"
76+
| "onClientInfoChange"
7477
>;
7578

7679
/** Connect a real MCP Client to our executor MCP server over in-memory transports. */
@@ -1610,6 +1613,77 @@ describe("MCP host server — skills tool", () => {
16101613
});
16111614
});
16121615

1616+
describe("MCP host server — client attribution", () => {
1617+
it("stamps the initialize-reported client identity on execution spans", async () => {
1618+
await withTracedClient(makeStubEngine({}), async (client, spans) => {
1619+
await client.callTool({ name: "execute", arguments: { code: "1+1" } });
1620+
1621+
const execute = spans.find((span) => span.name === "mcp.host.tool.execute");
1622+
expectDefined(execute);
1623+
expect(execute.attributes.get("mcp.client.name")).toBe("test-client");
1624+
expect(execute.attributes.get("mcp.client.version")).toBe("1.0.0");
1625+
});
1626+
});
1627+
1628+
it("reports the initialize-reported identity to onClientInfoChange once", async () => {
1629+
const reported: McpClientInfo[] = [];
1630+
await withClient(
1631+
makeStubEngine({}),
1632+
NO_CAPS,
1633+
async (client) => {
1634+
await client.callTool({ name: "execute", arguments: { code: "1+1" } });
1635+
expect(reported).toEqual([{ name: "test-client", version: "1.0.0" }]);
1636+
},
1637+
{
1638+
onClientInfoChange: (clientInfo) =>
1639+
Effect.sync(() => {
1640+
reported.push(clientInfo);
1641+
}),
1642+
},
1643+
);
1644+
});
1645+
});
1646+
1647+
describe("clientInfoFromRequestBody", () => {
1648+
const META_KEY = "io.modelcontextprotocol/clientInfo";
1649+
1650+
it("prefers the per-request _meta identity", () => {
1651+
expect(
1652+
clientInfoFromRequestBody({
1653+
method: "tools/call",
1654+
params: { _meta: { [META_KEY]: { name: "meta-client", version: "2.0.0" } } },
1655+
}),
1656+
).toEqual({ name: "meta-client", version: "2.0.0" });
1657+
});
1658+
1659+
it("falls back to the initialize body's native clientInfo", () => {
1660+
expect(
1661+
clientInfoFromRequestBody({
1662+
method: "initialize",
1663+
params: { clientInfo: { name: "init-client", version: "1.2.3", title: "Init Client" } },
1664+
}),
1665+
).toEqual({ name: "init-client", version: "1.2.3", title: "Init Client" });
1666+
});
1667+
1668+
it("decodes a malformed or absent identity to null", () => {
1669+
expect(clientInfoFromRequestBody(null)).toBeNull();
1670+
expect(clientInfoFromRequestBody({ method: "tools/call", params: {} })).toBeNull();
1671+
expect(
1672+
clientInfoFromRequestBody({
1673+
method: "tools/call",
1674+
params: { _meta: { [META_KEY]: { version: "no-name" } } },
1675+
}),
1676+
).toBeNull();
1677+
// Native clientInfo is only trusted on `initialize`, where the spec puts it.
1678+
expect(
1679+
clientInfoFromRequestBody({
1680+
method: "tools/call",
1681+
params: { clientInfo: { name: "misplaced" } },
1682+
}),
1683+
).toBeNull();
1684+
});
1685+
});
1686+
16131687
describe("MCP host server — hang-visibility tracing", () => {
16141688
it("execute emits a start marker and stamps the JSON-RPC id on execution spans", async () => {
16151689
const engine = makeStubEngine({});

0 commit comments

Comments
 (0)