Unified · Split
| @@ -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. | ||
| @@ -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(), |
| @@ -24,11 +24,13 @@ import { · 23 unmodified lines | |||
| 24 | import { | 24 | import { |
| 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; |
| @@ -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 | */ | ||
| 219 | export 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. | ||
| 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 | |||
| 785 | const startMarker = (name: string, attributes: Record<string, unknown>): Effect.Effect<void> => | 826 | const 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: the | 1235 | /** 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; |
| @@ -17,8 +17,10 @@ import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; · 16 unmodified lines | |||
| 17 | 17 | ||
| 18 | import { | 18 | import { |
| 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 | ||
| 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 | |||
| 1613 | describe("MCP host server — hang-visibility tracing", () => { | 1687 | describe("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({}); |
| @@ -9,12 +9,14 @@ import * as Cause from "effect/Cause"; · 8 unmodified lines | |||
| 9 | import { | 9 | import { |
| 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 | ||
| 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 | |||
| 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. */ |
| 253 | export const clientCapabilitiesFromRequestBody = ( | 294 | export 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 | sessionful | 511 | sessionful |
| 469 | ? elicitationSupportFromUnknown(server.server.getClientCapabilities()) | 512 | ? elicitationSupportFromUnknown(server.server.getClientCapabilities()) |