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
8The `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
379 // the negotiated apps support comes back from storage instead.379 // the negotiated apps support comes back from storage instead.
380 restoredAppsEnabled: sessionMeta.appsEnabled ?? false,380 restoredAppsEnabled: sessionMeta.appsEnabled ?? false,
381 onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled),381 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),
382 appsEnabled: false,386 appsEnabled: false,
383 sessionful: true,387 sessionful: true,
384 requestStateSigningKey: self.modernRequestStateSigningKey(),388 requestStateSigningKey: self.modernRequestStateSigningKey(),
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts+54−0
@@ -24,11 +24,13 @@ import { · 23 unmodified lines
24import {24import {
25 appsEnabledForClientCapabilities,25 appsEnabledForClientCapabilities,
26 clientCapabilitiesFromRequestBody,26 clientCapabilitiesFromRequestBody,
27 clientInfoFromRequestBody,
27 mcpRequestStateBindingFromBody,28 mcpRequestStateBindingFromBody,
28 PAUSED_APPROVAL_TIMEOUT_MS,29 PAUSED_APPROVAL_TIMEOUT_MS,
29 formatMcpExecutionOutcome,30 formatMcpExecutionOutcome,
30 mcpRequestStatePrincipal,31 mcpRequestStatePrincipal,
31 requestBodyFromRequest,32 requestBodyFromRequest,
33 type McpClientInfo,
32 type PausedExecutionHooks,34 type PausedExecutionHooks,
33 type ResumeFallbackOutcome,35 type ResumeFallbackOutcome,
34} from "@executor-js/host-mcp/tool-server";36} from "@executor-js/host-mcp/tool-server";
@@ -147,6 +149,14 @@ export interface SessionMeta { · 112 unmodified lines
147 * unknown, which behaves as disabled until the next `initialize`.149 * unknown, which behaves as disabled until the next `initialize`.
148 */150 */
149 readonly appsEnabled?: boolean;151 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;
150 /** Creation time of this session, retained across isolate eviction. */160 /** Creation time of this session, retained across isolate eviction. */
151 readonly createdAtMs?: number;161 readonly createdAtMs?: number;
152}162}
@@ -164,6 +174,8 @@ export interface ModernMcpServerRequestOptions { · 11 unmodified lines
164 readonly requestStateSigningKey: Uint8Array | string;174 readonly requestStateSigningKey: Uint8Array | string;
165 readonly requestStatePrincipal: string;175 readonly requestStatePrincipal: string;
166 readonly requestStateBinding?: string;176 readonly requestStateBinding?: string;
177 /** Client identity for span attribution: this request's `_meta`, else the session's persisted copy. */
178 readonly restoredClientInfo?: McpClientInfo;
167}179}
168 180
169/** Long-lived DO execution runtime shared by per-request MCP servers. */181/** Long-lived DO execution runtime shared by per-request MCP servers. */
@@ -515,6 +527,34 @@ export abstract class McpAgentSessionDOBase< · 345 unmodified lines
515 );527 );
516 }528 }
517 529
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
518 private async markActivity(now = Date.now()): Promise<void> {558 private async markActivity(now = Date.now()): Promise<void> {
519 this.lastActivityMs = now;559 this.lastActivityMs = now;
520 const key =560 const key =
@@ -606,6 +646,7 @@ export abstract class McpAgentSessionDOBase< · 85 unmodified lines
606 ...resolved,646 ...resolved,
607 ...(token.webOrigin ? { webOrigin: token.webOrigin } : {}),647 ...(token.webOrigin ? { webOrigin: token.webOrigin } : {}),
608 appsEnabled: stored?.appsEnabled ?? false,648 appsEnabled: stored?.appsEnabled ?? false,
649 ...(stored?.clientInfo ? { clientInfo: stored.clientInfo } : {}),
609 createdAtMs: stored?.createdAtMs ?? Date.now(),650 createdAtMs: stored?.createdAtMs ?? Date.now(),
610 };651 };
611 yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe(652 yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe(
@@ -816,6 +857,7 @@ export abstract class McpAgentSessionDOBase< · 204 unmodified lines
816 const parsedBody = self.modernRequestBodies.get(request);857 const parsedBody = self.modernRequestBodies.get(request);
817 const propagation = self.modernRequestPropagation.get(request);858 const propagation = self.modernRequestPropagation.get(request);
818 const capabilities = clientCapabilitiesFromRequestBody(parsedBody);859 const capabilities = clientCapabilitiesFromRequestBody(parsedBody);
860 const clientInfo = clientInfoFromRequestBody(parsedBody) ?? sessionMeta.clientInfo;
819 return Effect.runPromise(861 return Effect.runPromise(
820 Effect.gen(function* () {862 Effect.gen(function* () {
821 const requestStatePrincipal = mcpRequestStatePrincipal({863 const requestStatePrincipal = mcpRequestStatePrincipal({
@@ -834,6 +876,7 @@ export abstract class McpAgentSessionDOBase< · 12 unmodified lines
834 requestStateSigningKey: self.modernRequestStateSigningKey(),876 requestStateSigningKey: self.modernRequestStateSigningKey(),
835 requestStatePrincipal,877 requestStatePrincipal,
836 ...(requestStateBinding === null ? {} : { requestStateBinding }),878 ...(requestStateBinding === null ? {} : { requestStateBinding }),
879 ...(clientInfo === undefined ? {} : { restoredClientInfo: clientInfo }),
837 });880 });
838 }).pipe(881 }).pipe(
839 (effect) => self.withTelemetry(effect, propagation),882 (effect) => self.withTelemetry(effect, propagation),
@@ -1282,6 +1325,17 @@ export abstract class McpAgentSessionDOBase< · 442 unmodified lines
1282 });1325 });
1283 }1326 }
1284 1327
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
1285 this.modernRequestBodies.set(request, parsedBody);1339 this.modernRequestBodies.set(request, parsedBody);
1286 this.modernRequestPropagation.set(request, props.propagation);1340 this.modernRequestPropagation.set(request, props.propagation);
1287 this.modernRunningRequestCount += 1;1341 this.modernRunningRequestCount += 1;
packages/hosts/mcp/src/tool-server-core.ts+80−4
@@ -196,6 +196,30 @@ type SharedMcpServerConfig = { · 195 unmodified lines
196 * restore. Best-effort: failures are swallowed and never affect the session.196 * restore. Best-effort: failures are swallowed and never affect the session.
197 */197 */
198 readonly onAppsEnabledChange?: (appsEnabled: boolean) => Effect.Effect<void>;198 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 */
219export type McpClientInfo = {
220 readonly name: string;
221 readonly version?: string;
222 readonly title?: string;
199};223};
200 224
201/**225/**
@@ -340,6 +364,8 @@ export type ExecutorMcpAssembly<Server, RequestContext extends McpRequestJoinKey · 138 unmodified lines
340 readonly server: Server;364 readonly server: Server;
341 readonly initialAppsEnabled: boolean;365 readonly initialAppsEnabled: boolean;
342 readonly getClientCapabilities: () => unknown | null;366 readonly getClientCapabilities: () => unknown | null;
367 /** The live `initialize`-reported client identity, when the SDK has one. */
368 readonly getClientInfo: () => McpClientInfo | null;
343 readonly getElicitationSupport: () => { readonly form: boolean; readonly url: boolean };369 readonly getElicitationSupport: () => { readonly form: boolean; readonly url: boolean };
344 readonly getUiCapability: () => { readonly mimeTypes?: readonly string[] } | undefined;370 readonly getUiCapability: () => { readonly mimeTypes?: readonly string[] } | undefined;
345 readonly onInitialized: (callback: () => void) => void;371 readonly onInitialized: (callback: () => void) => void;
@@ -782,6 +808,21 @@ const joinKeyAttributes = (joinKeys: McpRequestJoinKeys): Record<string, unknown · 436 unmodified lines
782 "mcp.request.session_id": joinKeys.sessionId ?? "",808 "mcp.request.session_id": joinKeys.sessionId ?? "",
783});809});
784 810
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.
817const 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
785const startMarker = (name: string, attributes: Record<string, unknown>): Effect.Effect<void> =>826const startMarker = (name: string, attributes: Record<string, unknown>): Effect.Effect<void> =>
786 Effect.void.pipe(Effect.withSpan(name, { attributes }));827 Effect.void.pipe(Effect.withSpan(name, { attributes }));
787 828
@@ -1110,6 +1151,15 @@ export const buildExecutorMcpTools = < · 322 unmodified lines
1110 );1151 );
1111 const server = assembly.server;1152 const server = assembly.server;
1112 1153
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
1113 const executeWithNativeElicitation = (1163 const executeWithNativeElicitation = (
1114 code: string,1164 code: string,
1115 extra: RequestContext,1165 extra: RequestContext,
@@ -1179,7 +1229,7 @@ export const buildExecutorMcpTools = < · 63 unmodified lines
1179 "mcp.execute.code_length": code.length,1229 "mcp.execute.code_length": code.length,
1180 },1230 },
1181 }),1231 }),
1182 Effect.annotateSpans(joinKeyAttributes(extra)),1232 Effect.annotateSpans(requestSpanAttributes(extra)),
1183 );1233 );
1184 1234
1185 /** What the caller could bind an unresolved role to. Best effort: the1235 /** What the caller could bind an unresolved role to. Best effort: the
@@ -1340,7 +1390,7 @@ export const buildExecutorMcpTools = < · 154 unmodified lines
1340 "mcp.execute.execution_id": executionId,1390 "mcp.execute.execution_id": executionId,
1341 },1391 },
1342 }),1392 }),
1343 Effect.annotateSpans(joinKeyAttributes(extra)),1393 Effect.annotateSpans(requestSpanAttributes(extra)),
1344 );1394 );
1345 1395
1346 const requireUserResumeApproval = (executionId: string): Effect.Effect<McpToolResult> =>1396 const requireUserResumeApproval = (executionId: string): Effect.Effect<McpToolResult> =>
@@ -1419,7 +1469,7 @@ export const buildExecutorMcpTools = < · 72 unmodified lines
1419 "mcp.execute.execution_id": executionId,1469 "mcp.execute.execution_id": executionId,
1420 },1470 },
1421 }),1471 }),
1422 Effect.annotateSpans(joinKeyAttributes(extra)),1472 Effect.annotateSpans(requestSpanAttributes(extra)),
1423 );1473 );
1424 1474
1425 // --- tools ---1475 // --- tools ---
@@ -2169,9 +2219,35 @@ export const buildExecutorMcpTools = < · 743 unmodified lines
2169 });2219 });
2170 };2220 };
2171 2221
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
2172 yield* Effect.sync(() => {2244 yield* Effect.sync(() => {
2173 syncToolAvailability();2245 syncToolAvailability();
2174 assembly.onInitialized(syncToolAvailability);2246 syncClientInfo();
2247 assembly.onInitialized(() => {
2248 syncToolAvailability();
2249 syncClientInfo();
2250 });
2175 }).pipe(Effect.withSpan("mcp.host.sync_tool_availability"));2251 }).pipe(Effect.withSpan("mcp.host.sync_tool_availability"));
2176 2252
2177 return server;2253 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
17 17
18import {18import {
19 buildMcpServer,19 buildMcpServer,
20 clientInfoFromRequestBody,
20 formatMcpExecutionOutcome,21 formatMcpExecutionOutcome,
21 type ExecutorMcpServerConfig,22 type ExecutorMcpServerConfig,
23 type McpClientInfo,
22} from "./tool-server";24} from "./tool-server";
23 25
24// ---------------------------------------------------------------------------26// ---------------------------------------------------------------------------
@@ -71,6 +73,7 @@ type TestServerConfig<E extends Cause.YieldableError> = Pick< · 46 unmodified lines
71 | "pausedExecutionHooks"73 | "pausedExecutionHooks"
72 | "pausedExecutionLeaseMs"74 | "pausedExecutionLeaseMs"
73 | "resumeFallback"75 | "resumeFallback"
76 | "onClientInfoChange"
74>;77>;
75 78
76/** Connect a real MCP Client to our executor MCP server over in-memory transports. */79/** 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
1610 });1613 });
1611});1614});
1612 1615
1616describe("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
1647describe("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
1613describe("MCP host server — hang-visibility tracing", () => {1687describe("MCP host server — hang-visibility tracing", () => {
1614 it("execute emits a start marker and stamps the JSON-RPC id on execution spans", async () => {1688 it("execute emits a start marker and stamps the JSON-RPC id on execution spans", async () => {
1615 const engine = makeStubEngine({});1689 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
9import {9import {
10 acceptedContent,10 acceptedContent,
11 CLIENT_CAPABILITIES_META_KEY,11 CLIENT_CAPABILITIES_META_KEY,
12 CLIENT_INFO_META_KEY,
12 createRequestStateCodec,13 createRequestStateCodec,
13 fromJsonSchema,14 fromJsonSchema,
14 inputRequired,15 inputRequired,
15 inputResponse,16 inputResponse,
16 McpServer,17 McpServer,
17 type CallToolResult,18 type CallToolResult,
19 type Implementation,
18 type InputRequiredResult,20 type InputRequiredResult,
19 type ServerContext,21 type ServerContext,
20} from "@modelcontextprotocol/server";22} from "@modelcontextprotocol/server";
@@ -37,6 +39,7 @@ import { · 16 unmodified lines
37 buildExecutorMcpTools,39 buildExecutorMcpTools,
38 type ExecutorMcpAssembly,40 type ExecutorMcpAssembly,
39 type ExecutorMcpToolConfig,41 type ExecutorMcpToolConfig,
42 type McpClientInfo,
40 type McpHandlerResult,43 type McpHandlerResult,
41 type McpRequestJoinKeys,44 type McpRequestJoinKeys,
42 type McpToolResult,45 type McpToolResult,
@@ -48,6 +51,7 @@ export type { · 5 unmodified lines
48 BrowserApprovalStore,51 BrowserApprovalStore,
49 ExecutorMcpToolConfig,52 ExecutorMcpToolConfig,
50 McpArtifactsPort,53 McpArtifactsPort,
54 McpClientInfo,
51 McpConnectionsPort,55 McpConnectionsPort,
52 McpToolResult,56 McpToolResult,
53 PausedExecutionHooks,57 PausedExecutionHooks,
@@ -249,6 +253,43 @@ const elicitationSupportFromUnknown = ( · 195 unmodified lines
249 };253 };
250};254};
251 255
256const ClientInfoSchema = Schema.Struct({
257 name: Schema.String,
258 version: Schema.optional(Schema.String),
259 title: Schema.optional(Schema.String),
260});
261const decodeClientInfo = Schema.decodeUnknownOption(ClientInfoSchema);
262
263const toClientInfo = (value: unknown): McpClientInfo | null =>
264 Option.getOrNull(decodeClientInfo(value));
265
266const 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 */
283export 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
252/** Parse the MCP Apps capability subset from an already-decoded modern body. */293/** Parse the MCP Apps capability subset from an already-decoded modern body. */
253export const clientCapabilitiesFromRequestBody = (294export const clientCapabilitiesFromRequestBody = (
254 body: unknown,295 body: unknown,
@@ -464,6 +505,8 @@ const createMcpAssembly = <E extends Cause.YieldableError>( · 209 unmodified lines
464 initialAppsEnabled,505 initialAppsEnabled,
465 getClientCapabilities: () =>506 getClientCapabilities: () =>
466 sessionful ? (server.server.getClientCapabilities() ?? null) : null,507 sessionful ? (server.server.getClientCapabilities() ?? null) : null,
508 getClientInfo: () =>
509 sessionful ? clientInfoFromImplementation(server.server.getClientVersion()) : null,
467 getElicitationSupport: () =>510 getElicitationSupport: () =>
468 sessionful511 sessionful
469 ? elicitationSupportFromUnknown(server.server.getClientCapabilities())512 ? elicitationSupportFromUnknown(server.server.getClientCapabilities())