Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/mcp-client-span-attribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@executor-js/host-mcp": patch
"@executor-js/cloudflare": patch
---

**MCP execution spans now carry the client identity (`mcp.client.*`)**

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.
4 changes: 4 additions & 0 deletions apps/cloud/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,10 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD
// the negotiated apps support comes back from storage instead.
restoredAppsEnabled: sessionMeta.appsEnabled ?? false,
onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled),
// Same restore contract for the client identity that keys the
// `mcp.client.*` span attribution on execution spans.
...(sessionMeta.clientInfo ? { restoredClientInfo: sessionMeta.clientInfo } : {}),
onClientInfoChange: (clientInfo) => self.persistClientInfo(clientInfo),
appsEnabled: false,
sessionful: true,
requestStateSigningKey: self.modernRequestStateSigningKey(),
Expand Down
54 changes: 54 additions & 0 deletions packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ import {
import {
appsEnabledForClientCapabilities,
clientCapabilitiesFromRequestBody,
clientInfoFromRequestBody,
mcpRequestStateBindingFromBody,
PAUSED_APPROVAL_TIMEOUT_MS,
formatMcpExecutionOutcome,
mcpRequestStatePrincipal,
requestBodyFromRequest,
type McpClientInfo,
type PausedExecutionHooks,
type ResumeFallbackOutcome,
} from "@executor-js/host-mcp/tool-server";
Expand Down Expand Up @@ -147,6 +149,14 @@ export interface SessionMeta {
* unknown, which behaves as disabled until the next `initialize`.
*/
readonly appsEnabled?: boolean;
/**
* The client identity (`clientInfo`) self-reported at `initialize` or in a
* modern request's `_meta`. Persisted for the same reason as
* {@link appsEnabled}: a cold-restored server never sees an `initialize`,
* and without this the execution spans' `mcp.client.*` attribution vanishes
* mid-conversation. Telemetry/display only, never behavior.
*/
readonly clientInfo?: McpClientInfo;
/** Creation time of this session, retained across isolate eviction. */
readonly createdAtMs?: number;
}
Expand All @@ -164,6 +174,8 @@ export interface ModernMcpServerRequestOptions {
readonly requestStateSigningKey: Uint8Array | string;
readonly requestStatePrincipal: string;
readonly requestStateBinding?: string;
/** Client identity for span attribution: this request's `_meta`, else the session's persisted copy. */
readonly restoredClientInfo?: McpClientInfo;
}

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

/**
* Persist the client identity self-reported at `initialize` (or in a modern
* request's `_meta`), so a cold restore keeps span attribution. Subclasses
* hand this to `buildMcpServer` as `onClientInfoChange`; the modern request
* path calls it directly. Same no-op-before-meta contract as
* {@link persistAppsEnabled}.
*/
protected persistClientInfo(clientInfo: McpClientInfo): Effect.Effect<void> {
const self = this;
return Effect.gen(function* () {
const stored = yield* self.loadSessionMeta();
if (
!stored ||
(stored.clientInfo?.name === clientInfo.name &&
stored.clientInfo?.version === clientInfo.version &&
stored.clientInfo?.title === clientInfo.title)
) {
return;
}
yield* Effect.promise(() => self.saveSessionMeta({ ...stored, clientInfo }));
}).pipe(
Effect.withSpan("mcp.session.persist_client_info", {
attributes: { "mcp.client.name": clientInfo.name },
}),
Effect.ignoreCause({ log: false }),
);
}

private async markActivity(now = Date.now()): Promise<void> {
this.lastActivityMs = now;
const key =
Expand Down Expand Up @@ -606,6 +646,7 @@ export abstract class McpAgentSessionDOBase<
...resolved,
...(token.webOrigin ? { webOrigin: token.webOrigin } : {}),
appsEnabled: stored?.appsEnabled ?? false,
...(stored?.clientInfo ? { clientInfo: stored.clientInfo } : {}),
createdAtMs: stored?.createdAtMs ?? Date.now(),
};
yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe(
Expand Down Expand Up @@ -816,6 +857,7 @@ export abstract class McpAgentSessionDOBase<
const parsedBody = self.modernRequestBodies.get(request);
const propagation = self.modernRequestPropagation.get(request);
const capabilities = clientCapabilitiesFromRequestBody(parsedBody);
const clientInfo = clientInfoFromRequestBody(parsedBody) ?? sessionMeta.clientInfo;
return Effect.runPromise(
Effect.gen(function* () {
const requestStatePrincipal = mcpRequestStatePrincipal({
Expand All @@ -834,6 +876,7 @@ export abstract class McpAgentSessionDOBase<
requestStateSigningKey: self.modernRequestStateSigningKey(),
requestStatePrincipal,
...(requestStateBinding === null ? {} : { requestStateBinding }),
...(clientInfo === undefined ? {} : { restoredClientInfo: clientInfo }),
});
}).pipe(
(effect) => self.withTelemetry(effect, propagation),
Expand Down Expand Up @@ -1282,6 +1325,17 @@ export abstract class McpAgentSessionDOBase<
});
}

// Modern clients self-report identity per request (`_meta` clientInfo) or
// at `initialize`; persist it so meta-less requests and cold restores keep
// their span attribution. Best-effort by construction (persistClientInfo
// swallows failures) and a storage no-op unless the identity changed.
const reportedClientInfo = clientInfoFromRequestBody(parsedBody);
if (reportedClientInfo) {
await Effect.runPromise(
this.withTelemetry(this.persistClientInfo(reportedClientInfo), props.propagation),
);
}

this.modernRequestBodies.set(request, parsedBody);
this.modernRequestPropagation.set(request, props.propagation);
this.modernRunningRequestCount += 1;
Expand Down
84 changes: 80 additions & 4 deletions packages/hosts/mcp/src/tool-server-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,30 @@ type SharedMcpServerConfig = {
* restore. Best-effort: failures are swallowed and never affect the session.
*/
readonly onAppsEnabledChange?: (appsEnabled: boolean) => Effect.Effect<void>;
/**
* The client identity self-reported at a previous `initialize` (or in a
* modern request's `_meta`), restored for the same reason as
* {@link restoredAppsEnabled}. Feeds only the `mcp.client.*` span
* attributes on execution spans, never behavior or security decisions.
*/
readonly restoredClientInfo?: McpClientInfo;
/**
* Called when `initialize` reports the client identity, so the host can
* persist it for {@link restoredClientInfo} on a later cold restore.
* Best-effort: failures are swallowed and never affect the session.
*/
readonly onClientInfoChange?: (clientInfo: McpClientInfo) => Effect.Effect<void>;
};

/**
* Client software identity as self-reported over MCP (`clientInfo` at
* `initialize`, `_meta` on modern requests). Display and telemetry vocabulary
* only: the spec forbids relying on it for behavior or security.
*/
export type McpClientInfo = {
readonly name: string;
readonly version?: string;
readonly title?: string;
};

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

// Client identity uses the same `mcp.client.*` vocabulary as the cloud
// worker's `initialize` fingerprint (`annotateMcpRequest`), so execution spans
// segment by client directly instead of through a session join that `initialize`
// (which has no session id yet) cannot satisfy. Absent means no key at all, not
// an empty string, when no `initialize` was seen and nothing was restored:
// unknown is real state here, unlike the always-present session key above.
const clientInfoAttributes = (clientInfo: McpClientInfo | null): Record<string, unknown> =>
clientInfo === null
? {}
: {
"mcp.client.name": clientInfo.name,
...(clientInfo.version !== undefined ? { "mcp.client.version": clientInfo.version } : {}),
...(clientInfo.title !== undefined ? { "mcp.client.title": clientInfo.title } : {}),
};

const startMarker = (name: string, attributes: Record<string, unknown>): Effect.Effect<void> =>
Effect.void.pipe(Effect.withSpan(name, { attributes }));

Expand Down Expand Up @@ -1110,6 +1151,15 @@ export const buildExecutorMcpTools = <
);
const server = assembly.server;

// Seeded from the host's persisted copy; a live `initialize` on this
// instance replaces it via `syncClientInfo` below. Read lazily at each
// tool call so spans always carry the newest identity.
let clientInfo: McpClientInfo | null = config.restoredClientInfo ?? null;
const requestSpanAttributes = (joinKeys: McpRequestJoinKeys): Record<string, unknown> => ({
...joinKeyAttributes(joinKeys),
...clientInfoAttributes(clientInfo),
});

const executeWithNativeElicitation = (
code: string,
extra: RequestContext,
Expand Down Expand Up @@ -1179,7 +1229,7 @@ export const buildExecutorMcpTools = <
"mcp.execute.code_length": code.length,
},
}),
Effect.annotateSpans(joinKeyAttributes(extra)),
Effect.annotateSpans(requestSpanAttributes(extra)),
);

/** What the caller could bind an unresolved role to. Best effort: the
Expand Down Expand Up @@ -1340,7 +1390,7 @@ export const buildExecutorMcpTools = <
"mcp.execute.execution_id": executionId,
},
}),
Effect.annotateSpans(joinKeyAttributes(extra)),
Effect.annotateSpans(requestSpanAttributes(extra)),
);

const requireUserResumeApproval = (executionId: string): Effect.Effect<McpToolResult> =>
Expand Down Expand Up @@ -1419,7 +1469,7 @@ export const buildExecutorMcpTools = <
"mcp.execute.execution_id": executionId,
},
}),
Effect.annotateSpans(joinKeyAttributes(extra)),
Effect.annotateSpans(requestSpanAttributes(extra)),
);

// --- tools ---
Expand Down Expand Up @@ -2169,9 +2219,35 @@ export const buildExecutorMcpTools = <
});
};

// Client identity arrives with the same `initialize` that carries the
// capabilities above. An absent live value (cold restore, construction
// time) is not evidence the client changed, so the restored value stands,
// the same asymmetry `syncToolAvailability` documents for capabilities.
const syncClientInfo = () => {
const live = assembly.getClientInfo();
if (live === null) return;
const changed =
live.name !== clientInfo?.name ||
live.version !== clientInfo?.version ||
live.title !== clientInfo?.title;
if (!changed) return;
clientInfo = live;
const onClientInfoChange = config.onClientInfoChange;
if (onClientInfoChange) {
// 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
void Effect.runPromiseWith(context)(
onClientInfoChange(live).pipe(Effect.ignoreCause({ log: false })),
);
}
};

yield* Effect.sync(() => {
syncToolAvailability();
assembly.onInitialized(syncToolAvailability);
syncClientInfo();
assembly.onInitialized(() => {
syncToolAvailability();
syncClientInfo();
});
}).pipe(Effect.withSpan("mcp.host.sync_tool_availability"));

return server;
Expand Down
74 changes: 74 additions & 0 deletions packages/hosts/mcp/src/tool-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution";

import {
buildMcpServer,
clientInfoFromRequestBody,
formatMcpExecutionOutcome,
type ExecutorMcpServerConfig,
type McpClientInfo,
} from "./tool-server";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -71,6 +73,7 @@ type TestServerConfig<E extends Cause.YieldableError> = Pick<
| "pausedExecutionHooks"
| "pausedExecutionLeaseMs"
| "resumeFallback"
| "onClientInfoChange"
>;

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

describe("MCP host server — client attribution", () => {
it("stamps the initialize-reported client identity on execution spans", async () => {
await withTracedClient(makeStubEngine({}), async (client, spans) => {
await client.callTool({ name: "execute", arguments: { code: "1+1" } });

const execute = spans.find((span) => span.name === "mcp.host.tool.execute");
expectDefined(execute);
expect(execute.attributes.get("mcp.client.name")).toBe("test-client");
expect(execute.attributes.get("mcp.client.version")).toBe("1.0.0");
});
});

it("reports the initialize-reported identity to onClientInfoChange once", async () => {
const reported: McpClientInfo[] = [];
await withClient(
makeStubEngine({}),
NO_CAPS,
async (client) => {
await client.callTool({ name: "execute", arguments: { code: "1+1" } });
expect(reported).toEqual([{ name: "test-client", version: "1.0.0" }]);
},
{
onClientInfoChange: (clientInfo) =>
Effect.sync(() => {
reported.push(clientInfo);
}),
},
);
});
});

describe("clientInfoFromRequestBody", () => {
const META_KEY = "io.modelcontextprotocol/clientInfo";

it("prefers the per-request _meta identity", () => {
expect(
clientInfoFromRequestBody({
method: "tools/call",
params: { _meta: { [META_KEY]: { name: "meta-client", version: "2.0.0" } } },
}),
).toEqual({ name: "meta-client", version: "2.0.0" });
});

it("falls back to the initialize body's native clientInfo", () => {
expect(
clientInfoFromRequestBody({
method: "initialize",
params: { clientInfo: { name: "init-client", version: "1.2.3", title: "Init Client" } },
}),
).toEqual({ name: "init-client", version: "1.2.3", title: "Init Client" });
});

it("decodes a malformed or absent identity to null", () => {
expect(clientInfoFromRequestBody(null)).toBeNull();
expect(clientInfoFromRequestBody({ method: "tools/call", params: {} })).toBeNull();
expect(
clientInfoFromRequestBody({
method: "tools/call",
params: { _meta: { [META_KEY]: { version: "no-name" } } },
}),
).toBeNull();
// Native clientInfo is only trusted on `initialize`, where the spec puts it.
expect(
clientInfoFromRequestBody({
method: "tools/call",
params: { clientInfo: { name: "misplaced" } },
}),
).toBeNull();
});
});

describe("MCP host server — hang-visibility tracing", () => {
it("execute emits a start marker and stamps the JSON-RPC id on execution spans", async () => {
const engine = makeStubEngine({});
Expand Down
Loading
Loading