diff --git a/.changeset/sep-414-trace-context-meta-keys.md b/.changeset/sep-414-trace-context-meta-keys.md new file mode 100644 index 0000000000..f8f21b63aa --- /dev/null +++ b/.changeset/sep-414-trace-context-meta-keys.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +Add reserved trace context `_meta` key constants (`TRACEPARENT_META_KEY`, `TRACESTATE_META_KEY`, `BAGGAGE_META_KEY`) per SEP-414, plus docs and a passthrough regression test. The spec reserves the unprefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` (W3C Trace Context / W3C Baggage formats) for distributed tracing; the SDK passes them through untouched. diff --git a/docs/client.md b/docs/client.md index 0c852f4e11..8a4d1ba0f3 100644 --- a/docs/client.md +++ b/docs/client.md @@ -26,7 +26,9 @@ import { SdkError, SdkErrorCode, SSEClientTransport, - StreamableHTTPClientTransport + StreamableHTTPClientTransport, + TRACEPARENT_META_KEY, + TRACESTATE_META_KEY } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; ``` @@ -571,6 +573,58 @@ const transport = new StreamableHTTPClientTransport(new URL('http://localhost:30 }); ``` +## Trace context propagation + +The MCP specification ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)) reserves the unprefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` for distributed trace context, as an exception to the usual `_meta` key prefix rule. When present, the values must follow the [W3C Trace Context](https://www.w3.org/TR/trace-context/) and [W3C Baggage](https://www.w3.org/TR/baggage/) formats. The SDK does not interpret these keys — `_meta` passes through both directions untouched — so you can propagate OpenTelemetry context across any transport, including stdio where HTTP headers are unavailable. The key names are exported as `TRACEPARENT_META_KEY`, `TRACESTATE_META_KEY`, and `BAGGAGE_META_KEY`. + +Attach trace context to a single request via `_meta`: + +```ts source="../examples/client/src/clientGuide.examples.ts#traceContext_perRequest" +// Values would normally come from your tracer's active span context. +const result = await client.callTool({ + name: 'calculate-bmi', + arguments: { weightKg: 70, heightM: 1.75 }, + _meta: { + [TRACEPARENT_META_KEY]: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + [TRACESTATE_META_KEY]: 'vendor1=opaqueValue1' + } +}); +console.log(result.content); +``` + +Or inject it into every outgoing request with fetch middleware (Streamable HTTP transport): + +```ts source="../examples/client/src/clientGuide.examples.ts#traceContext_middleware" +const traceContextMiddleware = createMiddleware(async (next, input, init) => { + if (typeof init?.body !== 'string') { + return next(input, init); + } + const message = JSON.parse(init.body) as { + method?: string; + params?: { _meta?: Record; [key: string]: unknown }; + }; + // Only requests and notifications carry params._meta; skip responses. + if (message.method === undefined) { + return next(input, init); + } + message.params = { + ...message.params, + _meta: { + ...message.params?._meta, + // Replace with values from your tracer's active span context. + [TRACEPARENT_META_KEY]: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' + } + }; + return next(input, { ...init, body: JSON.stringify(message) }); +}); + +const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { + fetch: applyMiddlewares(traceContextMiddleware)(fetch) +}); +``` + +On the server side, handlers can read the incoming trace context from `ctx.mcpReq._meta` — see the [server guide](./server.md#trace-context-propagation). + ## Resumption tokens When using SSE-based streaming, the server can assign event IDs. Pass `onresumptiontoken` to track them, and `resumptionToken` to resume from where you left off after a disconnection: diff --git a/docs/server.md b/docs/server.md index b16c24fc4d..3cfbff5e78 100644 --- a/docs/server.md +++ b/docs/server.md @@ -22,7 +22,7 @@ import { randomUUID } from 'node:crypto'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { CallToolResult, ResourceLink } from '@modelcontextprotocol/server'; -import { completable, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import { completable, McpServer, ResourceTemplate, TRACEPARENT_META_KEY } from '@modelcontextprotocol/server'; import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; ``` @@ -378,6 +378,34 @@ server.registerTool( `progress` must increase on each call. `total` and `message` are optional. If the client does not provide a `progressToken`, skip the notification. +## Trace context propagation + +The MCP specification ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)) reserves the unprefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` for distributed trace context, as an exception to the usual `_meta` key prefix rule. When present, the values must follow the [W3C Trace Context](https://www.w3.org/TR/trace-context/) and [W3C Baggage](https://www.w3.org/TR/baggage/) formats. The SDK does not interpret these keys — `_meta` passes through untouched on any transport, including stdio. The key names are exported as `TRACEPARENT_META_KEY`, `TRACESTATE_META_KEY`, and `BAGGAGE_META_KEY`. + +Read the caller's trace context from `ctx.mcpReq._meta` in a handler: + +```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_traceContext" +server.registerTool( + 'traced-operation', + { + description: 'Operation that participates in distributed tracing', + inputSchema: z.object({ query: z.string() }) + }, + async ({ query }, ctx): Promise => { + // e.g. '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' + const traceparent = ctx.mcpReq._meta?.[TRACEPARENT_META_KEY]; + if (typeof traceparent === 'string') { + // Continue the caller's trace, e.g. start a child span with your + // OpenTelemetry tracer using this trace context. + } + + return { content: [{ type: 'text', text: `Results for ${query}` }] }; + } +); +``` + +To propagate context onward (for example on a server-initiated sampling request, or back on a response), set the same keys in the outgoing `_meta`. See the [client guide](./client.md#trace-context-propagation) for injecting trace context on the client side. + ## Server-initiated requests MCP is bidirectional — servers can send requests *to* the client during tool execution, as long as the client declares matching capabilities (see [Architecture](https://modelcontextprotocol.io/docs/learn/architecture) in the MCP overview). diff --git a/examples/client/src/clientGuide.examples.ts b/examples/client/src/clientGuide.examples.ts index 57853821ec..99a8383bc8 100644 --- a/examples/client/src/clientGuide.examples.ts +++ b/examples/client/src/clientGuide.examples.ts @@ -21,7 +21,9 @@ import { SdkError, SdkErrorCode, SSEClientTransport, - StreamableHTTPClientTransport + StreamableHTTPClientTransport, + TRACEPARENT_META_KEY, + TRACESTATE_META_KEY } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; //#endregion imports @@ -522,6 +524,55 @@ async function middleware_basic() { return transport; } +/** Example: Attach W3C Trace Context to a single request via `_meta`. */ +async function traceContext_perRequest(client: Client) { + //#region traceContext_perRequest + // Values would normally come from your tracer's active span context. + const result = await client.callTool({ + name: 'calculate-bmi', + arguments: { weightKg: 70, heightM: 1.75 }, + _meta: { + [TRACEPARENT_META_KEY]: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + [TRACESTATE_META_KEY]: 'vendor1=opaqueValue1' + } + }); + console.log(result.content); + //#endregion traceContext_perRequest +} + +/** Example: Client middleware that injects trace context into every outgoing request. */ +async function traceContext_middleware() { + //#region traceContext_middleware + const traceContextMiddleware = createMiddleware(async (next, input, init) => { + if (typeof init?.body !== 'string') { + return next(input, init); + } + const message = JSON.parse(init.body) as { + method?: string; + params?: { _meta?: Record; [key: string]: unknown }; + }; + // Only requests and notifications carry params._meta; skip responses. + if (message.method === undefined) { + return next(input, init); + } + message.params = { + ...message.params, + _meta: { + ...message.params?._meta, + // Replace with values from your tracer's active span context. + [TRACEPARENT_META_KEY]: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' + } + }; + return next(input, { ...init, body: JSON.stringify(message) }); + }); + + const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { + fetch: applyMiddlewares(traceContextMiddleware)(fetch) + }); + //#endregion traceContext_middleware + return transport; +} + /** Example: Track resumption tokens for SSE reconnection. */ async function resumptionToken_basic(client: Client) { //#region resumptionToken_basic @@ -572,4 +623,6 @@ void errorHandling_toolErrors; void errorHandling_lifecycle; void errorHandling_timeout; void middleware_basic; +void traceContext_perRequest; +void traceContext_middleware; void resumptionToken_basic; diff --git a/examples/server/src/serverGuide.examples.ts b/examples/server/src/serverGuide.examples.ts index 5a4712f830..1d4f2fd474 100644 --- a/examples/server/src/serverGuide.examples.ts +++ b/examples/server/src/serverGuide.examples.ts @@ -13,7 +13,7 @@ import { randomUUID } from 'node:crypto'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { CallToolResult, ResourceLink } from '@modelcontextprotocol/server'; -import { completable, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import { completable, McpServer, ResourceTemplate, TRACEPARENT_META_KEY } from '@modelcontextprotocol/server'; import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; //#endregion imports @@ -319,6 +319,29 @@ function registerTool_progress(server: McpServer) { //#endregion registerTool_progress } +/** Example: Tool that reads W3C Trace Context from request `_meta`. */ +function registerTool_traceContext(server: McpServer) { + //#region registerTool_traceContext + server.registerTool( + 'traced-operation', + { + description: 'Operation that participates in distributed tracing', + inputSchema: z.object({ query: z.string() }) + }, + async ({ query }, ctx): Promise => { + // e.g. '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' + const traceparent = ctx.mcpReq._meta?.[TRACEPARENT_META_KEY]; + if (typeof traceparent === 'string') { + // Continue the caller's trace, e.g. start a child span with your + // OpenTelemetry tracer using this trace context. + } + + return { content: [{ type: 'text', text: `Results for ${query}` }] }; + } + ); + //#endregion registerTool_traceContext +} + // --------------------------------------------------------------------------- // Server-initiated requests // --------------------------------------------------------------------------- @@ -543,6 +566,7 @@ void registerTool_errorHandling; void registerTool_annotations; void registerTool_logging; void registerTool_progress; +void registerTool_traceContext; void registerTool_sampling; void registerTool_elicitation; void registerTool_roots; diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index a3c98da6fe..6bb7f0dbd9 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -71,6 +71,7 @@ export * from '../../types/types.js'; // Constants export { + BAGGAGE_META_KEY, CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, DEFAULT_NEGOTIATED_PROTOCOL_VERSION, @@ -84,7 +85,9 @@ export { PARSE_ERROR, PROTOCOL_VERSION_META_KEY, RELATED_TASK_META_KEY, - SUPPORTED_PROTOCOL_VERSIONS + SUPPORTED_PROTOCOL_VERSIONS, + TRACEPARENT_META_KEY, + TRACESTATE_META_KEY } from '../../types/constants.js'; // Enums diff --git a/packages/core/src/types/constants.ts b/packages/core/src/types/constants.ts index d51fe926cc..018f9ecb51 100644 --- a/packages/core/src/types/constants.ts +++ b/packages/core/src/types/constants.ts @@ -37,6 +37,44 @@ export const CLIENT_CAPABILITIES_META_KEY = 'io.modelcontextprotocol/clientCapab */ export const LOG_LEVEL_META_KEY = 'io.modelcontextprotocol/logLevel'; +/* + * Reserved `_meta` keys for distributed trace context propagation (SEP-414). + * + * These unprefixed keys are reserved by the MCP specification as an explicit + * exception to the `_meta` key prefix rule. The SDK does not interpret them; + * they pass through `_meta` untouched for OpenTelemetry-style propagation. + */ + +/** + * `_meta` key carrying W3C Trace Context for distributed tracing (SEP-414). + * + * When present, the value MUST follow the W3C `traceparent` header format, + * e.g. `00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01`. + * + * @see https://www.w3.org/TR/trace-context/#traceparent-header + */ +export const TRACEPARENT_META_KEY = 'traceparent'; + +/** + * `_meta` key carrying vendor-specific trace state for distributed tracing (SEP-414). + * + * When present, the value MUST follow the W3C `tracestate` header format, + * e.g. `vendor1=value1,vendor2=value2`. + * + * @see https://www.w3.org/TR/trace-context/#tracestate-header + */ +export const TRACESTATE_META_KEY = 'tracestate'; + +/** + * `_meta` key carrying cross-cutting propagation values for distributed tracing (SEP-414). + * + * When present, the value MUST follow the W3C Baggage header format, + * e.g. `userId=alice,serverRegion=us-east-1`. + * + * @see https://www.w3.org/TR/baggage/ + */ +export const BAGGAGE_META_KEY = 'baggage'; + /* JSON-RPC types */ export const JSONRPC_VERSION = '2.0'; diff --git a/packages/core/test/shared/traceContextMeta.test.ts b/packages/core/test/shared/traceContextMeta.test.ts new file mode 100644 index 0000000000..ff8c0bbf9e --- /dev/null +++ b/packages/core/test/shared/traceContextMeta.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod/v4'; + +import { Protocol } from '../../src/shared/protocol.js'; +import type { BaseContext } from '../../src/exports/public/index.js'; +import { BAGGAGE_META_KEY, TRACEPARENT_META_KEY, TRACESTATE_META_KEY } from '../../src/exports/public/index.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; + +class TestProtocol extends Protocol { + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} +} + +async function pair(): Promise<[TestProtocol, TestProtocol]> { + const [t1, t2] = InMemoryTransport.createLinkedPair(); + const a = new TestProtocol(); + const b = new TestProtocol(); + await a.connect(t1); + await b.connect(t2); + return [a, b]; +} + +const TRACEPARENT = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'; +const TRACESTATE = 'vendor1=opaqueValue1,vendor2=opaqueValue2'; +const BAGGAGE = 'userId=alice,serverRegion=us-east-1'; + +describe('SEP-414 trace context `_meta` passthrough', () => { + it('exposes reserved unprefixed key names', () => { + // SEP-414 reserves these exact unprefixed keys as an exception to the + // `_meta` prefix rule; a drifted constant would break interop. + expect(TRACEPARENT_META_KEY).toBe('traceparent'); + expect(TRACESTATE_META_KEY).toBe('tracestate'); + expect(BAGGAGE_META_KEY).toBe('baggage'); + }); + + it('passes request `_meta` trace context through to the server-side handler untouched', async () => { + const [a, b] = await pair(); + let seenMeta: Record | undefined; + b.setRequestHandler('acme/traced', { params: z.object({ v: z.string() }) }, async (params, ctx) => { + seenMeta = ctx.mcpReq._meta; + return { echoed: params.v }; + }); + + await a.request( + { + method: 'acme/traced', + params: { + v: 'x', + _meta: { + [TRACEPARENT_META_KEY]: TRACEPARENT, + [TRACESTATE_META_KEY]: TRACESTATE, + [BAGGAGE_META_KEY]: BAGGAGE + } + } + }, + z.object({ echoed: z.string() }) + ); + + expect(seenMeta).toMatchObject({ + [TRACEPARENT_META_KEY]: TRACEPARENT, + [TRACESTATE_META_KEY]: TRACESTATE, + [BAGGAGE_META_KEY]: BAGGAGE + }); + }); + + it('passes response `_meta` trace context back to the requester untouched', async () => { + const [a, b] = await pair(); + b.setRequestHandler('acme/traced', { params: z.object({}) }, async (_params, ctx) => ({ + ok: true, + _meta: { + // Echo the inbound trace context onto the response envelope. + ...ctx.mcpReq._meta + } + })); + + const result = await a.request( + { + method: 'acme/traced', + params: { + _meta: { + [TRACEPARENT_META_KEY]: TRACEPARENT, + [TRACESTATE_META_KEY]: TRACESTATE, + [BAGGAGE_META_KEY]: BAGGAGE + } + } + }, + z.object({ ok: z.boolean(), _meta: z.record(z.string(), z.unknown()).optional() }) + ); + + expect(result.ok).toBe(true); + expect(result._meta).toMatchObject({ + [TRACEPARENT_META_KEY]: TRACEPARENT, + [TRACESTATE_META_KEY]: TRACESTATE, + [BAGGAGE_META_KEY]: BAGGAGE + }); + }); + + it('passes notification `_meta` trace context through to the handler', async () => { + const [a, b] = await pair(); + let seenMeta: unknown; + b.setNotificationHandler('acme/tracedEvent', { params: z.object({ stage: z.string() }) }, (_params, notification) => { + seenMeta = notification.params?._meta; + }); + + await a.notification({ + method: 'acme/tracedEvent', + params: { + stage: 'fetch', + _meta: { [TRACEPARENT_META_KEY]: TRACEPARENT, [BAGGAGE_META_KEY]: BAGGAGE } + } + }); + await new Promise(r => setTimeout(r, 0)); + + expect(seenMeta).toEqual({ [TRACEPARENT_META_KEY]: TRACEPARENT, [BAGGAGE_META_KEY]: BAGGAGE }); + }); +});