History

Stamp client identity on MCP execution spans (#1621)

Rhys Sullivan committed 2 months ago

Commit 86c68af1 parent 6dff8916 files · +263 −4Browse files

Unified · Split

6 files
  1. .changeset/mcp-client-span-attribution.md +8 −0
  2. apps/cloud/src/mcp/session-durable-object.ts +4 −0
  3. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +54 −0
  4. packages/hosts/mcp/src/tool-server-core.ts +80 −4
  5. packages/hosts/mcp/src/tool-server.test.ts +74 −0
  6. packages/hosts/mcp/src/tool-server.ts +43 −0
.changeset/mcp-client-span-attribution.md+8−0
@@ -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+4−0
@@ -379,6 +379,10 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 378 unmodified lines
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+54−0
@@ -24,11 +24,13 @@ import { · 23 unmodified lines
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 { · 112 unmodified lines
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 { · 11 unmodified lines
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< · 345 unmodified lines
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< · 85 unmodified lines
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< · 204 unmodified lines
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< · 12 unmodified lines
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< · 442 unmodified lines
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+80−4
@@ -196,6 +196,30 @@ type SharedMcpServerConfig = { · 195 unmodified lines
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 · 138 unmodified lines
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 · 436 unmodified lines
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 = < · 322 unmodified lines
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 = < · 63 unmodified lines
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 = < · 154 unmodified lines
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 = < · 72 unmodified lines
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 = < · 743 unmodified lines
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+74−0
@@ -17,8 +17,10 @@ import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; · 16 unmodified lines
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< · 46 unmodified lines
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", () => { · 1533 unmodified lines
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({});
packages/hosts/mcp/src/tool-server.ts+43−0
@@ -9,12 +9,14 @@ import * as Cause from "effect/Cause"; · 8 unmodified lines
99 import {
1010 acceptedContent,
1111 CLIENT_CAPABILITIES_META_KEY,
12+ CLIENT_INFO_META_KEY,
1213 createRequestStateCodec,
1314 fromJsonSchema,
1415 inputRequired,
1516 inputResponse,
1617 McpServer,
1718 type CallToolResult,
19+ type Implementation,
1820 type InputRequiredResult,
1921 type ServerContext,
2022 } from "@modelcontextprotocol/server";
@@ -37,6 +39,7 @@ import { · 16 unmodified lines
3739 buildExecutorMcpTools,
3840 type ExecutorMcpAssembly,
3941 type ExecutorMcpToolConfig,
42+ type McpClientInfo,
4043 type McpHandlerResult,
4144 type McpRequestJoinKeys,
4245 type McpToolResult,
@@ -48,6 +51,7 @@ export type { · 5 unmodified lines
4851 BrowserApprovalStore,
4952 ExecutorMcpToolConfig,
5053 McpArtifactsPort,
54+ McpClientInfo,
5155 McpConnectionsPort,
5256 McpToolResult,
5357 PausedExecutionHooks,
@@ -249,6 +253,43 @@ const elicitationSupportFromUnknown = ( · 195 unmodified lines
249253 };
250254 };
251255
256+const ClientInfoSchema = Schema.Struct({
257+ name: Schema.String,
258+ version: Schema.optional(Schema.String),
259+ title: Schema.optional(Schema.String),
260+});
261+const decodeClientInfo = Schema.decodeUnknownOption(ClientInfoSchema);
262+
263+const toClientInfo = (value: unknown): McpClientInfo | null =>
264+ Option.getOrNull(decodeClientInfo(value));
265+
266+const clientInfoFromImplementation = (
267+ implementation: Implementation | undefined,
268+): McpClientInfo | null =>
269+ implementation === undefined
270+ ? null
271+ : {
272+ name: implementation.name,
273+ version: implementation.version,
274+ ...(implementation.title !== undefined ? { title: implementation.title } : {}),
275+ };
276+
277+/**
278+ * Parse the self-reported client identity from a modern request's `_meta`
279+ * envelope (`io.modelcontextprotocol/clientInfo`, sent per request), falling
280+ * back to the `initialize` body's native `params.clientInfo`. Malformed or
281+ * absent identity decodes to null: it is display/telemetry data, never load-bearing.
282+ */
283+export const clientInfoFromRequestBody = (body: unknown): McpClientInfo | null => {
284+ if (!isRecord(body)) return null;
285+ const params = body.params;
286+ if (!isRecord(params)) return null;
287+ const metadata = params._meta;
288+ const fromMeta = isRecord(metadata) ? toClientInfo(metadata[CLIENT_INFO_META_KEY]) : null;
289+ if (fromMeta) return fromMeta;
290+ return body.method === "initialize" ? toClientInfo(params.clientInfo) : null;
291+};
292+
252293 /** Parse the MCP Apps capability subset from an already-decoded modern body. */
253294 export const clientCapabilitiesFromRequestBody = (
254295 body: unknown,
@@ -464,6 +505,8 @@ const createMcpAssembly = <E extends Cause.YieldableError>( · 209 unmodified lines
464505 initialAppsEnabled,
465506 getClientCapabilities: () =>
466507 sessionful ? (server.server.getClientCapabilities() ?? null) : null,
508+ getClientInfo: () =>
509+ sessionful ? clientInfoFromImplementation(server.server.getClientVersion()) : null,
467510 getElicitationSupport: () =>
468511 sessionful
469512 ? elicitationSupportFromUnknown(server.server.getClientCapabilities())