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 | 379 | // the negotiated apps support comes back from storage instead. |
| 380 | 380 | restoredAppsEnabled: sessionMeta.appsEnabled ?? false, |
| 381 | 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 | 386 | appsEnabled: false, |
| 383 | 387 | sessionful: true, |
| 384 | 388 | requestStateSigningKey: self.modernRequestStateSigningKey(), |
| @@ -24,11 +24,13 @@ import { · 23 unmodified lines | ||
| 24 | 24 | import { |
| 25 | 25 | appsEnabledForClientCapabilities, |
| 26 | 26 | clientCapabilitiesFromRequestBody, |
| 27 | + clientInfoFromRequestBody, | |
| 27 | 28 | mcpRequestStateBindingFromBody, |
| 28 | 29 | PAUSED_APPROVAL_TIMEOUT_MS, |
| 29 | 30 | formatMcpExecutionOutcome, |
| 30 | 31 | mcpRequestStatePrincipal, |
| 31 | 32 | requestBodyFromRequest, |
| 33 | + type McpClientInfo, | |
| 32 | 34 | type PausedExecutionHooks, |
| 33 | 35 | type ResumeFallbackOutcome, |
| 34 | 36 | } from "@executor-js/host-mcp/tool-server"; |
| @@ -147,6 +149,14 @@ export interface SessionMeta { · 112 unmodified lines | ||
| 147 | 149 | * unknown, which behaves as disabled until the next `initialize`. |
| 148 | 150 | */ |
| 149 | 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 | 160 | /** Creation time of this session, retained across isolate eviction. */ |
| 151 | 161 | readonly createdAtMs?: number; |
| 152 | 162 | } |
| @@ -164,6 +174,8 @@ export interface ModernMcpServerRequestOptions { · 11 unmodified lines | ||
| 164 | 174 | readonly requestStateSigningKey: Uint8Array | string; |
| 165 | 175 | readonly requestStatePrincipal: string; |
| 166 | 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 | 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 | 558 | private async markActivity(now = Date.now()): Promise<void> { |
| 519 | 559 | this.lastActivityMs = now; |
| 520 | 560 | const key = |
| @@ -606,6 +646,7 @@ export abstract class McpAgentSessionDOBase< · 85 unmodified lines | ||
| 606 | 646 | ...resolved, |
| 607 | 647 | ...(token.webOrigin ? { webOrigin: token.webOrigin } : {}), |
| 608 | 648 | appsEnabled: stored?.appsEnabled ?? false, |
| 649 | + ...(stored?.clientInfo ? { clientInfo: stored.clientInfo } : {}), | |
| 609 | 650 | createdAtMs: stored?.createdAtMs ?? Date.now(), |
| 610 | 651 | }; |
| 611 | 652 | yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe( |
| @@ -816,6 +857,7 @@ export abstract class McpAgentSessionDOBase< · 204 unmodified lines | ||
| 816 | 857 | const parsedBody = self.modernRequestBodies.get(request); |
| 817 | 858 | const propagation = self.modernRequestPropagation.get(request); |
| 818 | 859 | const capabilities = clientCapabilitiesFromRequestBody(parsedBody); |
| 860 | + const clientInfo = clientInfoFromRequestBody(parsedBody) ?? sessionMeta.clientInfo; | |
| 819 | 861 | return Effect.runPromise( |
| 820 | 862 | Effect.gen(function* () { |
| 821 | 863 | const requestStatePrincipal = mcpRequestStatePrincipal({ |
| @@ -834,6 +876,7 @@ export abstract class McpAgentSessionDOBase< · 12 unmodified lines | ||
| 834 | 876 | requestStateSigningKey: self.modernRequestStateSigningKey(), |
| 835 | 877 | requestStatePrincipal, |
| 836 | 878 | ...(requestStateBinding === null ? {} : { requestStateBinding }), |
| 879 | + ...(clientInfo === undefined ? {} : { restoredClientInfo: clientInfo }), | |
| 837 | 880 | }); |
| 838 | 881 | }).pipe( |
| 839 | 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 | 1339 | this.modernRequestBodies.set(request, parsedBody); |
| 1286 | 1340 | this.modernRequestPropagation.set(request, props.propagation); |
| 1287 | 1341 | this.modernRunningRequestCount += 1; |
| @@ -196,6 +196,30 @@ type SharedMcpServerConfig = { · 195 unmodified lines | ||
| 196 | 196 | * restore. Best-effort: failures are swallowed and never affect the session. |
| 197 | 197 | */ |
| 198 | 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 | 364 | readonly server: Server; |
| 341 | 365 | readonly initialAppsEnabled: boolean; |
| 342 | 366 | readonly getClientCapabilities: () => unknown | null; |
| 367 | + /** The live `initialize`-reported client identity, when the SDK has one. */ | |
| 368 | + readonly getClientInfo: () => McpClientInfo | null; | |
| 343 | 369 | readonly getElicitationSupport: () => { readonly form: boolean; readonly url: boolean }; |
| 344 | 370 | readonly getUiCapability: () => { readonly mimeTypes?: readonly string[] } | undefined; |
| 345 | 371 | readonly onInitialized: (callback: () => void) => void; |
| @@ -782,6 +808,21 @@ const joinKeyAttributes = (joinKeys: McpRequestJoinKeys): Record<string, unknown · 436 unmodified lines | ||
| 782 | 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 | 826 | const startMarker = (name: string, attributes: Record<string, unknown>): Effect.Effect<void> => |
| 786 | 827 | Effect.void.pipe(Effect.withSpan(name, { attributes })); |
| 787 | 828 | |
| @@ -1110,6 +1151,15 @@ export const buildExecutorMcpTools = < · 322 unmodified lines | ||
| 1110 | 1151 | ); |
| 1111 | 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 | 1163 | const executeWithNativeElicitation = ( |
| 1114 | 1164 | code: string, |
| 1115 | 1165 | extra: RequestContext, |
| @@ -1179,7 +1229,7 @@ export const buildExecutorMcpTools = < · 63 unmodified lines | ||
| 1179 | 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 | 1235 | /** What the caller could bind an unresolved role to. Best effort: the |
| @@ -1340,7 +1390,7 @@ export const buildExecutorMcpTools = < · 154 unmodified lines | ||
| 1340 | 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 | 1396 | const requireUserResumeApproval = (executionId: string): Effect.Effect<McpToolResult> => |
| @@ -1419,7 +1469,7 @@ export const buildExecutorMcpTools = < · 72 unmodified lines | ||
| 1419 | 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 | 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 | 2244 | yield* Effect.sync(() => { |
| 2173 | 2245 | syncToolAvailability(); |
| 2174 | − assembly.onInitialized(syncToolAvailability); | |
| 2246 | + syncClientInfo(); | |
| 2247 | + assembly.onInitialized(() => { | |
| 2248 | + syncToolAvailability(); | |
| 2249 | + syncClientInfo(); | |
| 2250 | + }); | |
| 2175 | 2251 | }).pipe(Effect.withSpan("mcp.host.sync_tool_availability")); |
| 2176 | 2252 | |
| 2177 | 2253 | return server; |
| @@ -17,8 +17,10 @@ import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; · 16 unmodified lines | ||
| 17 | 17 | |
| 18 | 18 | import { |
| 19 | 19 | buildMcpServer, |
| 20 | + clientInfoFromRequestBody, | |
| 20 | 21 | formatMcpExecutionOutcome, |
| 21 | 22 | type ExecutorMcpServerConfig, |
| 23 | + type McpClientInfo, | |
| 22 | 24 | } from "./tool-server"; |
| 23 | 25 | |
| 24 | 26 | // --------------------------------------------------------------------------- |
| @@ -71,6 +73,7 @@ type TestServerConfig<E extends Cause.YieldableError> = Pick< · 46 unmodified lines | ||
| 71 | 73 | | "pausedExecutionHooks" |
| 72 | 74 | | "pausedExecutionLeaseMs" |
| 73 | 75 | | "resumeFallback" |
| 76 | + | "onClientInfoChange" | |
| 74 | 77 | >; |
| 75 | 78 | |
| 76 | 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 | 1687 | describe("MCP host server — hang-visibility tracing", () => { |
| 1614 | 1688 | it("execute emits a start marker and stamps the JSON-RPC id on execution spans", async () => { |
| 1615 | 1689 | const engine = makeStubEngine({}); |
| @@ -9,12 +9,14 @@ import * as Cause from "effect/Cause"; · 8 unmodified lines | ||
| 9 | 9 | import { |
| 10 | 10 | acceptedContent, |
| 11 | 11 | CLIENT_CAPABILITIES_META_KEY, |
| 12 | + CLIENT_INFO_META_KEY, | |
| 12 | 13 | createRequestStateCodec, |
| 13 | 14 | fromJsonSchema, |
| 14 | 15 | inputRequired, |
| 15 | 16 | inputResponse, |
| 16 | 17 | McpServer, |
| 17 | 18 | type CallToolResult, |
| 19 | + type Implementation, | |
| 18 | 20 | type InputRequiredResult, |
| 19 | 21 | type ServerContext, |
| 20 | 22 | } from "@modelcontextprotocol/server"; |
| @@ -37,6 +39,7 @@ import { · 16 unmodified lines | ||
| 37 | 39 | buildExecutorMcpTools, |
| 38 | 40 | type ExecutorMcpAssembly, |
| 39 | 41 | type ExecutorMcpToolConfig, |
| 42 | + type McpClientInfo, | |
| 40 | 43 | type McpHandlerResult, |
| 41 | 44 | type McpRequestJoinKeys, |
| 42 | 45 | type McpToolResult, |
| @@ -48,6 +51,7 @@ export type { · 5 unmodified lines | ||
| 48 | 51 | BrowserApprovalStore, |
| 49 | 52 | ExecutorMcpToolConfig, |
| 50 | 53 | McpArtifactsPort, |
| 54 | + McpClientInfo, | |
| 51 | 55 | McpConnectionsPort, |
| 52 | 56 | McpToolResult, |
| 53 | 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 | 293 | /** Parse the MCP Apps capability subset from an already-decoded modern body. */ |
| 253 | 294 | export const clientCapabilitiesFromRequestBody = ( |
| 254 | 295 | body: unknown, |
| @@ -464,6 +505,8 @@ const createMcpAssembly = <E extends Cause.YieldableError>( · 209 unmodified lines | ||
| 464 | 505 | initialAppsEnabled, |
| 465 | 506 | getClientCapabilities: () => |
| 466 | 507 | sessionful ? (server.server.getClientCapabilities() ?? null) : null, |
| 508 | + getClientInfo: () => | |
| 509 | + sessionful ? clientInfoFromImplementation(server.server.getClientVersion()) : null, | |
| 467 | 510 | getElicitationSupport: () => |
| 468 | 511 | sessionful |
| 469 | 512 | ? elicitationSupportFromUnknown(server.server.getClientCapabilities()) |