feat(core): reserved trace context _meta keys and propagation docs (SEP-414) (#2270)
Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
This commit is contained in:
parent
e84c3e9ad0
commit
78fbe2736d
8 changed files with 330 additions and 5 deletions
5
.changeset/sep-414-trace-context-meta-keys.md
Normal file
5
.changeset/sep-414-trace-context-meta-keys.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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';
|
||||
```
|
||||
|
|
@ -580,6 +582,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<string, unknown>; [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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
```
|
||||
|
|
@ -384,6 +384,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<CallToolResult> => {
|
||||
// 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).
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>; [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;
|
||||
|
|
|
|||
|
|
@ -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<CallToolResult> => {
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
120
packages/core/test/shared/traceContextMeta.test.ts
Normal file
120
packages/core/test/shared/traceContextMeta.test.ts
Normal file
|
|
@ -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<BaseContext> {
|
||||
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<string, unknown> | 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 });
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue