From 1110a0c9043f5d120a6091211d82f138f6a76ae0 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:56:30 +0100 Subject: [PATCH 1/4] test(integration): fix cloudflare workers test leaking workerd processes (#2296) --- .../test/server/cloudflareWorkers.test.ts | 284 +++++++++++++----- 1 file changed, 205 insertions(+), 79 deletions(-) diff --git a/test/integration/test/server/cloudflareWorkers.test.ts b/test/integration/test/server/cloudflareWorkers.test.ts index c32f1dc96c..863b534db3 100644 --- a/test/integration/test/server/cloudflareWorkers.test.ts +++ b/test/integration/test/server/cloudflareWorkers.test.ts @@ -7,16 +7,103 @@ import type { ChildProcess } from 'node:child_process'; import { execSync, spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; import * as fs from 'node:fs'; +import { createRequire } from 'node:module'; +import * as net from 'node:net'; import * as os from 'node:os'; import path from 'node:path'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -const PORT = 8787; const READINESS_TIMEOUT_MS = 60_000; const READINESS_POLL_INTERVAL_MS = 100; +const SHUTDOWN_GRACE_MS = 5000; + +/** + * Embedded in the worker's `serverInfo.version` and asserted by the readiness probe, so a + * leftover server from an earlier run can never satisfy the probe for this run. + */ +const SERVER_VERSION_NONCE = randomUUID(); + +/** + * The workspace's own wrangler installation (pinned by pnpm-lock). Running it directly — + * instead of `npm install`-ing wrangler into the temp project and going through `npx` — + * keeps the wrangler version deterministic, avoids a ~90MB download per test run, and + * removes two shell layers from the spawned process tree. Resolved through the package's + * `bin` field rather than a hardcoded internal file path. + */ +const WRANGLER_BIN = (() => { + const pkgPath = createRequire(import.meta.url).resolve('wrangler/package.json'); + const bin = (JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { bin: Record }).bin.wrangler; + return path.resolve(path.dirname(pkgPath), bin); +})(); + +/** Ask the kernel for a currently-free port instead of hardcoding one. */ +async function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const probe = net.createServer(); + probe.unref(); + probe.on('error', reject); + probe.listen(0, '127.0.0.1', () => { + const { port } = probe.address() as net.AddressInfo; + probe.close(() => resolve(port)); + }); + }); +} + +/** + * Kill the whole `wrangler dev` process tree. + * + * `wrangler dev` fans out into several processes (wrangler bin shim → wrangler CLI → + * esbuild service + two workerd instances), and non-interactive wrangler installs no + * SIGTERM handler that would dispose them. Signalling just `proc.pid` kills the top of + * the tree and orphans workerd, which keeps running — and keeps its port bound — + * indefinitely. So the process is spawned `detached` (own process group) and the whole + * group is signalled here, with a SIGKILL sweep afterwards because a wedged workerd can + * ignore SIGTERM. Orphaned workerd is a known recurring wrangler bug class (e.g. + * cloudflare/workers-sdk#9193); Cloudflare's own CI harness likewise tree-kills rather + * than trusting signal propagation. + * + * Caveat: if the test runner itself dies abruptly (`kill -9`, OOM), nothing here runs and + * the detached tree is orphaned; a process 'exit' guard in beforeAll covers ordinary + * fatal exits, and an orphan can't affect later runs (ephemeral port + readiness nonce). + */ +async function killWranglerTree(proc: ChildProcess): Promise { + if (proc.pid === undefined) { + return; + } + if (process.platform === 'win32') { + try { + execSync(`taskkill /pid ${proc.pid} /T /F`, { stdio: 'ignore' }); + } catch { + // Tree already gone + } + return; + } + try { + process.kill(-proc.pid, 'SIGTERM'); + } catch { + // Group already gone + } + // Wait on 'exit', not 'close': surviving grandchildren inherit the stdio pipes, so + // 'close' can stay pending long after wrangler itself died. + if (proc.exitCode === null && proc.signalCode === null) { + await new Promise(resolve => { + const timer = setTimeout(resolve, SHUTDOWN_GRACE_MS); + proc.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); + } + try { + process.kill(-proc.pid, 'SIGKILL'); + } catch { + // Group already gone — the expected case after a clean SIGTERM shutdown + } +} /** * Wait until the worker can serve a real MCP `initialize` request. @@ -24,34 +111,48 @@ const READINESS_POLL_INTERVAL_MS = 100; * Wrangler's "Ready on …" stdout line is unreliable: miniflare can print it before the user * worker is actually wired, and subsequent POSTs come back as `500 Network connection lost` or * `ECONNREFUSED`. The only signal we can trust is "the server returned an MCP-shaped response - * to a protocol request". + * to a protocol request" — and specifically a response carrying this run's + * {@link SERVER_VERSION_NONCE}, so a stale server from a previous run can't pass the probe. * - * Polls the configured port with an MCP `initialize` POST every {@link READINESS_POLL_INTERVAL_MS}ms - * until either a JSON-RPC result body comes back, the wrangler process exits, or - * {@link READINESS_TIMEOUT_MS} elapses. + * Polls the given port with an MCP `initialize` POST every {@link READINESS_POLL_INTERVAL_MS}ms + * until either a matching JSON-RPC result body comes back, the wrangler process exits, or + * {@link READINESS_TIMEOUT_MS} elapses. Each probe is individually bounded so a wedged server + * that accepts connections but never responds can't stall the loop. */ -async function waitForMcpReady(proc: ChildProcess): Promise { +async function waitForMcpReady(proc: ChildProcess, port: number): Promise { let stderrTail = ''; proc.stderr?.on('data', d => { stderrTail = (stderrTail + d.toString()).slice(-2048); }); + // Keep stdout flowing too: nothing reads it otherwise, and a full pipe buffer would make + // wrangler queue log writes in memory for as long as the server runs. + let stdoutTail = ''; + proc.stdout?.on('data', d => { + stdoutTail = (stdoutTail + d.toString()).slice(-2048); + }); - let processExitedWithCode: number | null = null; - proc.on('exit', code => { - processExitedWithCode = code ?? -1; + let processFailure: string | null = null; + proc.on('exit', (code, signal) => { + processFailure = signal === null ? `exited with code ${code ?? -1}` : `was killed by ${signal}`; + }); + proc.on('error', error => { + processFailure = `failed to spawn: ${error.message}`; }); const deadline = Date.now() + READINESS_TIMEOUT_MS; let lastFailure = 'no attempts made'; while (Date.now() < deadline) { - if (processExitedWithCode !== null) { - throw new Error(`wrangler dev exited with code ${processExitedWithCode} before becoming ready.\nstderr tail:\n${stderrTail}`); + if (processFailure !== null) { + throw new Error( + `wrangler dev ${processFailure} before becoming ready.\nstdout tail:\n${stdoutTail}\nstderr tail:\n${stderrTail}` + ); } try { - const response = await fetch(`http://127.0.0.1:${PORT}/`, { + const response = await fetch(`http://127.0.0.1:${port}/`, { method: 'POST', + signal: AbortSignal.timeout(2000), headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' @@ -68,7 +169,7 @@ async function waitForMcpReady(proc: ChildProcess): Promise { }) }); const body = await response.text(); - if (response.ok && body.includes('"jsonrpc"') && body.includes('"result"')) { + if (response.ok && body.includes('"jsonrpc"') && body.includes('"result"') && body.includes(SERVER_VERSION_NONCE)) { return; } lastFailure = `status=${response.status} body=${body.slice(0, 200)}`; @@ -80,57 +181,77 @@ async function waitForMcpReady(proc: ChildProcess): Promise { } throw new Error( - `Worker did not become ready within ${READINESS_TIMEOUT_MS}ms.\nLast probe: ${lastFailure}\nstderr tail:\n${stderrTail}` + `Worker did not become ready within ${READINESS_TIMEOUT_MS}ms.\nLast probe: ${lastFailure}\nstdout tail:\n${stdoutTail}\nstderr tail:\n${stderrTail}` ); } describe('Cloudflare Workers compatibility (no nodejs_compat)', () => { + let port = 0; let cleanup: (() => Promise) | null = null; beforeAll(async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cf-worker-test-')); + let proc: ChildProcess | null = null; + let orphanGuard: (() => void) | null = null; - // Pack server package - const serverPkgPath = path.resolve(__dirname, '../../../../packages/server'); - const packOutput = execSync(`pnpm pack --pack-destination ${tempDir}`, { - cwd: serverPkgPath, - encoding: 'utf8' - }); - const tarballName = path.basename(packOutput.trim().split('\n').pop()!); - - // Write package.json - const pkgJson = { - name: 'cf-worker-test', - private: true, - type: 'module', - dependencies: { - '@modelcontextprotocol/server': `file:./${tarballName}` - }, - devDependencies: { - wrangler: '^4.14.4' + // Registered before anything can fail (including a vitest hook timeout, which skips + // the catch below but still runs afterAll): kill the process tree, then the temp dir. + cleanup = async () => { + if (orphanGuard) { + process.removeListener('exit', orphanGuard); + orphanGuard = null; + } + if (proc) { + await killWranglerTree(proc); + } + try { + fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } catch { + // Ignore cleanup errors } }; - fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify(pkgJson, null, 2)); - - // Write wrangler config - const wranglerConfig = { - $schema: 'node_modules/wrangler/config-schema.json', - name: 'cf-worker-test', - main: 'server.ts', - compatibility_date: '2025-01-01' - }; - fs.writeFileSync(path.join(tempDir, 'wrangler.jsonc'), JSON.stringify(wranglerConfig, null, 2)); - // Write server source - const serverSource = ` + try { + // Pack server package + const serverPkgPath = path.resolve(__dirname, '../../../../packages/server'); + const packOutput = execSync(`pnpm pack --pack-destination "${tempDir}"`, { + cwd: serverPkgPath, + encoding: 'utf8', + timeout: 60_000 + }); + const tarballName = path.basename(packOutput.trim().split('\n').pop()!); + + // Write package.json + const pkgJson = { + name: 'cf-worker-test', + private: true, + type: 'module', + dependencies: { + '@modelcontextprotocol/server': `file:./${tarballName}` + } + }; + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify(pkgJson, null, 2)); + + // Write wrangler config + const wranglerConfig = { + name: 'cf-worker-test', + main: 'server.ts', + compatibility_date: '2025-01-01' + }; + fs.writeFileSync(path.join(tempDir, 'wrangler.jsonc'), JSON.stringify(wranglerConfig, null, 2)); + + // Write server source + const serverSource = ` import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { z } from 'zod'; -const server = new McpServer({ name: "test-server", version: "1.0.0" }); +const server = new McpServer({ name: "test-server", version: "${SERVER_VERSION_NONCE}" }); server.registerTool("greet", { - description: "Greet someone" -}, async (args) => ({ - content: [{ type: "text", text: "Hello, " + (args.name || "World") + "!" }] + description: "Greet someone", + inputSchema: z.object({ name: z.string() }) +}, async ({ name }) => ({ + content: [{ type: "text", text: "Hello, " + name + "!" }] })); const transport = new WebStandardStreamableHTTPServerTransport(); @@ -140,51 +261,56 @@ export default { fetch: (request) => transport.handleRequest(request) }; `; - fs.writeFileSync(path.join(tempDir, 'server.ts'), serverSource); + fs.writeFileSync(path.join(tempDir, 'server.ts'), serverSource); - // Install dependencies - execSync('npm install', { cwd: tempDir, stdio: 'pipe', timeout: 60_000 }); + // Install dependencies (just the packed server tarball — wrangler comes from the + // workspace, see WRANGLER_BIN) + execSync('npm install --no-audit --no-fund --prefer-offline', { cwd: tempDir, stdio: 'pipe', timeout: 60_000 }); - // Start wrangler dev server. Readiness is determined by probing the MCP endpoint, not by - // parsing wrangler's stdout — see waitForMcpReady for the reasoning. - const proc = spawn('npx', ['wrangler', 'dev', '--local', '--port', String(PORT)], { - cwd: tempDir, - shell: true, - stdio: 'pipe' - }); + // Start wrangler dev directly from the workspace installation, in its own process + // group so the whole tree can be torn down — see killWranglerTree. Readiness is + // determined by probing the MCP endpoint, not by parsing wrangler's stdout — see + // waitForMcpReady for the reasoning. + port = await getFreePort(); + proc = spawn(process.execPath, [WRANGLER_BIN, 'dev', '--local', '--port', String(port)], { + cwd: tempDir, + stdio: 'pipe', + detached: process.platform !== 'win32' + }); - try { - await waitForMcpReady(proc); + // Best-effort orphan guard: if the runner dies without running afterAll + // (process.exit, fatal error), take the detached tree down with it. Signal + // deaths (SIGKILL, OOM) can't be intercepted — see killWranglerTree. + if (process.platform !== 'win32' && proc.pid !== undefined) { + const pgid = proc.pid; + orphanGuard = () => { + try { + process.kill(-pgid, 'SIGKILL'); + } catch { + // Tree already gone + } + }; + process.once('exit', orphanGuard); + } + + await waitForMcpReady(proc, port); } catch (error) { - proc.kill('SIGTERM'); + await cleanup(); throw error; } - - cleanup = async () => { - proc.kill('SIGTERM'); - await new Promise(resolve => { - proc.on('close', () => resolve()); - setTimeout(resolve, 5000); - }); - try { - fs.rmSync(tempDir, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } - }; - }, 120_000); + }, 180_000); afterAll(async () => { await cleanup?.(); - }); + }, 30_000); it('should handle MCP requests', async () => { const client = new Client({ name: 'test-client', version: '1.0.0' }); - const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${PORT}/`)); + const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/`)); await client.connect(transport); - const result = await client.callTool({ name: 'greet', arguments: { name: 'World' } }); - expect(result.content).toEqual([{ type: 'text', text: 'Hello, World!' }]); + const result = await client.callTool({ name: 'greet', arguments: { name: 'Workers' } }); + expect(result.content).toEqual([{ type: 'text', text: 'Hello, Workers!' }]); await client.close(); }, 30_000); From 49c0a711c8bf2d385f9e03b4f28ba0ff0d0db0bd Mon Sep 17 00:00:00 2001 From: Matt <77928207+mattzcarey@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:19:45 +0100 Subject: [PATCH 2/4] feat: deprecate roots, sampling, and logging runtime APIs (SEP-2577) (#2268) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/sep-2577-deprecate-runtime-apis.md | 7 ++++ docs/client.md | 9 +++++ docs/server.md | 9 +++++ packages/client/src/client/client.ts | 30 +++++++++++++++-- packages/core/src/shared/protocol.ts | 8 +++++ packages/core/src/types/schemas.ts | 12 +++++++ packages/core/src/types/types.ts | 14 ++++++++ packages/server/src/server/mcp.ts | 4 +++ packages/server/src/server/server.ts | 33 +++++++++++++++++++ 9 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 .changeset/sep-2577-deprecate-runtime-apis.md diff --git a/.changeset/sep-2577-deprecate-runtime-apis.md b/.changeset/sep-2577-deprecate-runtime-apis.md new file mode 100644 index 0000000000..60afaaceb2 --- /dev/null +++ b/.changeset/sep-2577-deprecate-runtime-apis.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/client': patch +--- + +Mark the roots, sampling, and logging runtime APIs as `@deprecated` per SEP-2577 (deprecated as of protocol version 2026-07-28; functional for at least twelve months). Annotates `Server.createMessage`/`listRoots`/`sendLoggingMessage`, `McpServer.sendLoggingMessage`, `Client.setLoggingLevel`/`sendRootsListChanged`, the `ServerContext.mcpReq.log`/`requestSampling` helpers, and the `roots`/`sampling`/`logging` capability schema fields. JSDoc/docs only — no behavior change. diff --git a/docs/client.md b/docs/client.md index 0c852f4e11..bfc13eeecd 100644 --- a/docs/client.md +++ b/docs/client.md @@ -404,6 +404,9 @@ client.setNotificationHandler('notifications/resources/list_changed', async () = }); ``` +> [!WARNING] +> MCP logging (including `setLoggingLevel()` and `notifications/message`) is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Servers should migrate to stderr logging (STDIO) or OpenTelemetry. + To control the minimum severity of log messages the server sends, use {@linkcode @modelcontextprotocol/client!client/client.Client#setLoggingLevel | setLoggingLevel()}: ```ts source="../examples/client/src/clientGuide.examples.ts#setLoggingLevel_basic" @@ -431,6 +434,9 @@ const client = new Client( ### Sampling +> [!WARNING] +> Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Servers should migrate to calling LLM provider APIs directly. + When a server needs an LLM completion during tool execution, it sends a `sampling/createMessage` request to the client (see [Sampling](https://modelcontextprotocol.io/docs/learn/client-concepts#sampling) in the MCP overview). Register a handler to fulfill it: ```ts source="../examples/client/src/clientGuide.examples.ts#sampling_handler" @@ -472,6 +478,9 @@ For a full form-based elicitation handler with AJV validation, see [`simpleStrea ### Roots +> [!WARNING] +> Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to passing paths via tool parameters, resource URIs, or configuration. + Roots let the client expose filesystem boundaries to the server (see [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) in the MCP overview). Declare the `roots` capability and register a `roots/list` handler: ```ts source="../examples/client/src/clientGuide.examples.ts#roots_handler" diff --git a/docs/server.md b/docs/server.md index b16c24fc4d..57aca89b8c 100644 --- a/docs/server.md +++ b/docs/server.md @@ -312,6 +312,9 @@ server.registerPrompt( ## Logging +> [!WARNING] +> MCP logging is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to stderr logging (STDIO servers) or OpenTelemetry. + Logging lets your server send structured diagnostics — debug traces, progress updates, warnings — to the connected client as notifications (see [Logging](https://modelcontextprotocol.io/specification/latest/server/utilities/logging) in the MCP specification). Declare the `logging` capability, then call `ctx.mcpReq.log(level, data)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside any handler: @@ -384,6 +387,9 @@ MCP is bidirectional — servers can send requests *to* the client during tool e ### Sampling +> [!WARNING] +> Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to calling LLM provider APIs directly from your server. + Sampling lets a tool handler request an LLM completion from the connected client — the handler describes a prompt and the client returns the model's response (see [Sampling](https://modelcontextprotocol.io/docs/learn/client-concepts#sampling) in the MCP overview). Use sampling when a tool needs the model to generate or transform text mid-execution. Call `ctx.mcpReq.requestSampling(params)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside a tool handler: @@ -478,6 +484,9 @@ For runnable examples, see [`elicitationFormExample.ts`](https://github.com/mode ### Roots +> [!WARNING] +> Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to passing paths via tool parameters, resource URIs, or configuration. + Roots let a tool handler discover the client's workspace directories — for example, to scope a file search or identify project boundaries (see [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) in the MCP overview). Call {@linkcode @modelcontextprotocol/server!server/server.Server#listRoots | server.server.listRoots()} (requires the client to declare the `roots` capability): ```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_roots" diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 36a98521cd..95312cf8c5 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -189,6 +189,12 @@ export type ClientOptions = ProtocolOptions & { * `sampling/createMessage` and `elicitation/create`, the handler is automatically wrapped with * schema validation for both the incoming request and the returned result. * + * Note: the `roots/list` and `sampling/createMessage` handler surfaces (and the corresponding + * `roots` and `sampling` capabilities) are deprecated as of protocol version 2026-07-28 + * (SEP-2577). They remain functional during the deprecation window (at least twelve months). + * Migrate sampling to calling LLM provider APIs directly, and roots to passing paths via tool + * parameters, resource URIs, or configuration. + * * @example Handling a sampling request * ```ts source="./client.examples.ts#Client_setRequestHandler_sampling" * client.setRequestHandler('sampling/createMessage', async request => { @@ -499,6 +505,8 @@ export class Client extends Protocol { protected assertCapabilityForMethod(method: RequestMethod | string): void { switch (method as ClientRequest['method']) { + // Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + // functional during the deprecation window (at least twelve months). case 'logging/setLevel': { if (!this._serverCapabilities?.logging) { throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support logging (required for ${method})`); @@ -562,6 +570,8 @@ export class Client extends Protocol { protected assertNotificationCapability(method: NotificationMethod | string): void { switch (method as ClientNotification['method']) { + // Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + // functional during the deprecation window (at least twelve months). case 'notifications/roots/list_changed': { if (!this._capabilities.roots?.listChanged) { throw new SdkError( @@ -591,6 +601,8 @@ export class Client extends Protocol { protected assertRequestHandlerCapability(method: string): void { switch (method) { + // Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + // functional during the deprecation window (at least twelve months). case 'sampling/createMessage': { if (!this._capabilities.sampling) { throw new SdkError( @@ -611,6 +623,8 @@ export class Client extends Protocol { break; } + // Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + // functional during the deprecation window (at least twelve months). case 'roots/list': { if (!this._capabilities.roots) { throw new SdkError( @@ -637,7 +651,13 @@ export class Client extends Protocol { return this._requestWithSchema({ method: 'completion/complete', params }, CompleteResultSchema, options); } - /** Sets the minimum severity level for log messages sent by the server. */ + /** + * Sets the minimum severity level for log messages sent by the server. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains functional during the deprecation window (at least twelve months). + * Migrate to stderr logging (STDIO servers) or OpenTelemetry. + */ async setLoggingLevel(level: LoggingLevel, options?: RequestOptions) { return this._requestWithSchema({ method: 'logging/setLevel', params: { level } }, EmptyResultSchema, options); } @@ -935,7 +955,13 @@ export class Client extends Protocol { this.setNotificationHandler(notificationMethod, handler); } - /** Notifies the server that the client's root list has changed. Requires the `roots.listChanged` capability. */ + /** + * Notifies the server that the client's root list has changed. Requires the `roots.listChanged` capability. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains functional during the deprecation window (at least twelve months). + * Migrate to passing paths via tool parameters, resource URIs, or configuration. + */ async sendRootsListChanged() { return this.notification({ method: 'notifications/roots/list_changed' }); } diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index ed78cc68d0..16d2181018 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -211,6 +211,10 @@ export type ServerContext = BaseContext & { /** * Send a log message notification to the client. * Respects the client's log level filter set via logging/setLevel. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains functional during the deprecation window (at least twelve months). + * Migrate to stderr logging (STDIO servers) or OpenTelemetry. */ log: (level: LoggingLevel, data: unknown, logger?: string) => Promise; @@ -221,6 +225,10 @@ export type ServerContext = BaseContext & { /** * Request LLM sampling from the client. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains functional during the deprecation window (at least twelve months). + * Migrate to calling LLM provider APIs directly. */ requestSampling: ( params: CreateMessageRequest['params'], diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index f472a36ff9..fe850284e2 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -421,6 +421,10 @@ export const ClientCapabilitiesSchema = z.object({ experimental: z.record(z.string(), JSONObjectSchema).optional(), /** * Present if the client supports sampling from an LLM. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. */ sampling: z .object({ @@ -441,6 +445,10 @@ export const ClientCapabilitiesSchema = z.object({ elicitation: ElicitationCapabilitySchema.optional(), /** * Present if the client supports listing roots. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to passing paths via + * tool parameters, resource URIs, or configuration. */ roots: z .object({ @@ -486,6 +494,10 @@ export const ServerCapabilitiesSchema = z.object({ experimental: z.record(z.string(), JSONObjectSchema).optional(), /** * Present if the server supports sending log messages to the client. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to stderr logging + * (STDIO servers) or OpenTelemetry. */ logging: JSONObjectSchema.optional(), /** diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index 123de7fe84..e0fe28b500 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -225,9 +225,23 @@ export type Role = Infer; /* Initialization */ export type Implementation = Infer; +/** + * Capabilities a client may support. + * + * Note: the `roots` and `sampling` capabilities are deprecated as of protocol + * version 2026-07-28 (SEP-2577); they remain in the specification for at least + * twelve months. See `ClientCapabilitiesSchema`. + */ export type ClientCapabilities = Infer; export type InitializeRequestParams = Infer; export type InitializeRequest = Infer; +/** + * Capabilities a server may support. + * + * Note: the `logging` capability is deprecated as of protocol version + * 2026-07-28 (SEP-2577); it remains in the specification for at least twelve + * months. See `ServerCapabilitiesSchema`. + */ export type ServerCapabilities = Infer; export type InitializeResult = Infer; export type InitializedNotification = Infer; diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 40ec8bb1eb..0bdc813404 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -926,6 +926,10 @@ export class McpServer { * data: 'Processing complete' * }); * ``` + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains functional during the deprecation window (at least twelve months). + * Migrate to stderr logging (STDIO servers) or OpenTelemetry. */ async sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string) { return this.server.sendLoggingMessage(params, sessionId); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 9e117f05c3..9f2dc5ba51 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -113,6 +113,13 @@ export class Server extends Protocol { } } + /** + * Registers the built-in `logging/setLevel` request handler. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains functional during the deprecation window (at least twelve months). + * Migrate to stderr logging (STDIO servers) or OpenTelemetry. + */ private _registerLoggingHandler(): void { this.setRequestHandler('logging/setLevel', async (request, ctx) => { const transportSessionId: string | undefined = @@ -133,6 +140,9 @@ export class Server extends Protocol { ...ctx, mcpReq: { ...ctx.mcpReq, + // Deprecated as of protocol version 2026-07-28 (SEP-2577): `log` and + // `requestSampling` remain functional during the deprecation window + // (at least twelve months). See ServerContext for migration guidance. log: (level, data, logger) => this.sendLoggingMessage({ level, data, logger }), elicitInput: (params, options) => this.elicitInput(params, options), requestSampling: (params, options) => this.createMessage(params, options) @@ -410,18 +420,30 @@ export class Server extends Protocol { /** * Request LLM sampling from the client (without tools). * Returns single content block for backwards compatibility. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains functional during the deprecation window (at least twelve months). + * Migrate to calling LLM provider APIs directly. */ async createMessage(params: CreateMessageRequestParamsBase, options?: RequestOptions): Promise; /** * Request LLM sampling from the client with tool support. * Returns content that may be a single block or array (for parallel tool calls). + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains functional during the deprecation window (at least twelve months). + * Migrate to calling LLM provider APIs directly. */ async createMessage(params: CreateMessageRequestParamsWithTools, options?: RequestOptions): Promise; /** * Request LLM sampling from the client. * When tools may or may not be present, returns the union type. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains functional during the deprecation window (at least twelve months). + * Migrate to calling LLM provider APIs directly. */ async createMessage( params: CreateMessageRequest['params'], @@ -576,6 +598,13 @@ export class Server extends Protocol { ); } + /** + * Requests the list of roots from the client. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains functional during the deprecation window (at least twelve months). + * Migrate to passing paths via tool parameters, resource URIs, or configuration. + */ async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions) { return this._requestWithSchema({ method: 'roots/list', params }, ListRootsResultSchema, options); } @@ -586,6 +615,10 @@ export class Server extends Protocol { * @see {@linkcode LoggingMessageNotification} * @param params * @param sessionId Optional for stateless transports and backward compatibility. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains functional during the deprecation window (at least twelve months). + * Migrate to stderr logging (STDIO servers) or OpenTelemetry. */ async sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string) { if (this._capabilities.logging && !this.isMessageIgnored(params.level, sessionId)) { From e84c3e9ad040eb09299b1f99dd8bdd14251ae790 Mon Sep 17 00:00:00 2001 From: Matt <77928207+mattzcarey@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:25:35 +0100 Subject: [PATCH 3/4] fix(server,client): non-SEP draft spec conformance (eager list handlers, pagination docs, path-sanitization note) (#2269) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/draft-spec-non-sep-conformance.md | 9 ++ docs/server.md | 3 + packages/client/src/client/client.examples.ts | 9 +- packages/client/src/client/client.ts | 9 +- packages/server/src/server/mcp.ts | 18 +++ packages/server/src/server/server.ts | 7 + .../test/server/declaredCapabilities.test.ts | 152 ++++++++++++++++++ 7 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 .changeset/draft-spec-non-sep-conformance.md create mode 100644 test/integration/test/server/declaredCapabilities.test.ts diff --git a/.changeset/draft-spec-non-sep-conformance.md b/.changeset/draft-spec-non-sep-conformance.md new file mode 100644 index 0000000000..d57d1f34d6 --- /dev/null +++ b/.changeset/draft-spec-non-sep-conformance.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/client': patch +--- + +Non-SEP draft spec conformance fixes + +- `McpServer` now eagerly installs list/read/call handlers for every primitive capability (`tools`, `resources`, `prompts`) declared in `ServerOptions.capabilities`. Per the draft spec, a server that declares a capability MUST respond to its list method (potentially with an empty result) instead of returning "Method not found". Previously, handlers were only installed lazily on first registration, so a server constructed with e.g. `capabilities: { tools: {} }` and zero registered tools answered `tools/list` with `-32601`. Low-level `Server` users remain responsible for registering handlers for declared capabilities (documented on `ServerOptions.capabilities`). +- Fixed pagination doc examples on `Client.listTools`/`listPrompts`/`listResources` to loop `while (cursor !== undefined)` instead of `while (cursor)` — per the draft spec, clients MUST NOT treat an empty-string cursor as the end of results. diff --git a/docs/server.md b/docs/server.md index 57aca89b8c..f8c325bd32 100644 --- a/docs/server.md +++ b/docs/server.md @@ -252,6 +252,9 @@ server.registerResource( ); ``` +> [!IMPORTANT] +> **Security note:** If a resource is backed by the filesystem (for example, a `file://` server or a template whose variables map onto file paths), the spec requires sanitizing any user-influenced path before use. Resolve the requested path and verify it stays within the intended root directory, rejecting traversal sequences such as `..` (including encoded forms) and symlinks that escape the root. Never pass template variables or client-supplied URIs to filesystem APIs unchecked. + ## Prompts Prompts are reusable templates that help structure interactions with models (see [Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) in the MCP overview). Use a prompt when you want to offer a canned interaction pattern that users invoke explicitly; use a [tool](#tools) when the LLM should decide when to call it. diff --git a/packages/client/src/client/client.examples.ts b/packages/client/src/client/client.examples.ts index b08694cfbd..0789b1501a 100644 --- a/packages/client/src/client/client.examples.ts +++ b/packages/client/src/client/client.examples.ts @@ -143,11 +143,12 @@ async function Client_listTools_pagination(client: Client) { //#region Client_listTools_pagination const allTools: Tool[] = []; let cursor: string | undefined; + // Note: an empty-string cursor is valid and does not signal the end of results. do { const { tools, nextCursor } = await client.listTools({ cursor }); allTools.push(...tools); cursor = nextCursor; - } while (cursor); + } while (cursor !== undefined); console.log( 'Available tools:', allTools.map(t => t.name) @@ -162,11 +163,12 @@ async function Client_listPrompts_pagination(client: Client) { //#region Client_listPrompts_pagination const allPrompts: Prompt[] = []; let cursor: string | undefined; + // Note: an empty-string cursor is valid and does not signal the end of results. do { const { prompts, nextCursor } = await client.listPrompts({ cursor }); allPrompts.push(...prompts); cursor = nextCursor; - } while (cursor); + } while (cursor !== undefined); console.log( 'Available prompts:', allPrompts.map(p => p.name) @@ -181,11 +183,12 @@ async function Client_listResources_pagination(client: Client) { //#region Client_listResources_pagination const allResources: Resource[] = []; let cursor: string | undefined; + // Note: an empty-string cursor is valid and does not signal the end of results. do { const { resources, nextCursor } = await client.listResources({ cursor }); allResources.push(...resources); cursor = nextCursor; - } while (cursor); + } while (cursor !== undefined); console.log( 'Available resources:', allResources.map(r => r.name) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 95312cf8c5..bc3a91150b 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -677,11 +677,12 @@ export class Client extends Protocol { * ```ts source="./client.examples.ts#Client_listPrompts_pagination" * const allPrompts: Prompt[] = []; * let cursor: string | undefined; + * // Note: an empty-string cursor is valid and does not signal the end of results. * do { * const { prompts, nextCursor } = await client.listPrompts({ cursor }); * allPrompts.push(...prompts); * cursor = nextCursor; - * } while (cursor); + * } while (cursor !== undefined); * console.log( * 'Available prompts:', * allPrompts.map(p => p.name) @@ -707,11 +708,12 @@ export class Client extends Protocol { * ```ts source="./client.examples.ts#Client_listResources_pagination" * const allResources: Resource[] = []; * let cursor: string | undefined; + * // Note: an empty-string cursor is valid and does not signal the end of results. * do { * const { resources, nextCursor } = await client.listResources({ cursor }); * allResources.push(...resources); * cursor = nextCursor; - * } while (cursor); + * } while (cursor !== undefined); * console.log( * 'Available resources:', * allResources.map(r => r.name) @@ -870,11 +872,12 @@ export class Client extends Protocol { * ```ts source="./client.examples.ts#Client_listTools_pagination" * const allTools: Tool[] = []; * let cursor: string | undefined; + * // Note: an empty-string cursor is valid and does not signal the end of results. * do { * const { tools, nextCursor } = await client.listTools({ cursor }); * allTools.push(...tools); * cursor = nextCursor; - * } while (cursor); + * } while (cursor !== undefined); * console.log( * 'Available tools:', * allTools.map(t => t.name) diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 0bdc813404..5e9115391d 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -70,6 +70,21 @@ export class McpServer { constructor(serverInfo: Implementation, options?: ServerOptions) { this.server = new Server(serverInfo, options); + + // Per the MCP spec, a server that declares a primitive capability MUST respond to its + // list method (potentially with an empty result) rather than "Method not found" — even + // if nothing has been registered yet. Handlers are normally installed lazily on first + // registration, so eagerly install them here for any capability declared up front. + // (Users of the low-level `Server` class remain responsible for their own handlers.) + if (options?.capabilities?.tools) { + this.setToolRequestHandlers(); + } + if (options?.capabilities?.resources) { + this.setResourceRequestHandlers(); + } + if (options?.capabilities?.prompts) { + this.setPromptRequestHandlers(); + } } /** @@ -111,6 +126,9 @@ export class McpServer { } }); + // Note: tools are listed in registration (insertion) order, which keeps the ordering + // deterministic across requests when the underlying tool set has not changed, as + // recommended by the spec. this.server.setRequestHandler( 'tools/list', (): ListToolsResult => ({ diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 9f2dc5ba51..f96d8ec1bc 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -54,6 +54,13 @@ import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims' export type ServerOptions = ProtocolOptions & { /** * Capabilities to advertise as being supported by this server. + * + * Note: per the MCP spec, a server that declares a capability MUST respond to that + * capability's requests (e.g. `tools/list` for `tools`) — potentially with an empty + * result — rather than with a "Method not found" error. {@linkcode server/mcp.McpServer | McpServer} + * handles this automatically for capabilities declared here; when using the low-level + * {@linkcode Server} directly, you are responsible for registering a request handler for + * every capability you declare. */ capabilities?: ServerCapabilities; diff --git a/test/integration/test/server/declaredCapabilities.test.ts b/test/integration/test/server/declaredCapabilities.test.ts new file mode 100644 index 0000000000..60e3f847bb --- /dev/null +++ b/test/integration/test/server/declaredCapabilities.test.ts @@ -0,0 +1,152 @@ +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport, ProtocolErrorCode } from '@modelcontextprotocol/core'; +import { McpServer } from '@modelcontextprotocol/server'; +import { describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; + +async function connect(mcpServer: McpServer): Promise { + const client = new Client({ name: 'test client', version: '1.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + return client; +} + +describe('declared capabilities answer list methods (draft spec)', () => { + /*** + * Test: a server that declares a primitive capability MUST respond to its list method + * (with an empty result) even if nothing has been registered yet, rather than + * returning "Method not found". + */ + test('declared-but-empty tools/resources/prompts capabilities answer list methods with empty arrays', async () => { + const mcpServer = new McpServer( + { name: 'test server', version: '1.0' }, + { capabilities: { tools: {}, resources: {}, prompts: {} } } + ); + + const client = await connect(mcpServer); + + await expect(client.listTools()).resolves.toEqual({ tools: [] }); + await expect(client.listResources()).resolves.toEqual({ resources: [] }); + await expect(client.listResourceTemplates()).resolves.toEqual({ resourceTemplates: [] }); + await expect(client.listPrompts()).resolves.toEqual({ prompts: [] }); + }); + + /*** + * Test: calling an unknown tool on a declared-but-empty tools capability returns + * an "Invalid params" error, not "Method not found". + */ + test('tools/call for an unknown tool returns InvalidParams when tools capability is declared', async () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }, { capabilities: { tools: {} } }); + + const client = await connect(mcpServer); + + await expect(client.callTool({ name: 'nonexistent' })).rejects.toMatchObject({ + code: ProtocolErrorCode.InvalidParams + }); + }); + + /*** + * Test: capabilities that were NOT declared (and have no registrations) still return + * "Method not found" on the wire. Raw requests are used because the Client's + * convenience list methods short-circuit locally when the server does not advertise + * the corresponding capability. + */ + test('undeclared capabilities still return MethodNotFound', async () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }, { capabilities: { tools: {} } }); + + const client = await connect(mcpServer); + + await expect(client.listTools()).resolves.toEqual({ tools: [] }); + await expect(client.request({ method: 'resources/list' })).rejects.toMatchObject({ + code: ProtocolErrorCode.MethodNotFound + }); + await expect(client.request({ method: 'prompts/list' })).rejects.toMatchObject({ + code: ProtocolErrorCode.MethodNotFound + }); + }); + + /*** + * Test: a server constructed without declared capabilities behaves as before — + * list handlers are installed lazily on first registration. + */ + test('no declared capabilities and no registrations returns MethodNotFound for all list methods', async () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }); + + const client = await connect(mcpServer); + + await expect(client.request({ method: 'tools/list' })).rejects.toMatchObject({ + code: ProtocolErrorCode.MethodNotFound + }); + await expect(client.request({ method: 'resources/list' })).rejects.toMatchObject({ + code: ProtocolErrorCode.MethodNotFound + }); + await expect(client.request({ method: 'prompts/list' })).rejects.toMatchObject({ + code: ProtocolErrorCode.MethodNotFound + }); + }); + + /*** + * Test: registering primitives after declaring the capability up front continues to work + * (the eagerly installed handlers list later registrations). + */ + test('registrations made after construction are listed by the eagerly installed handlers', async () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }, { capabilities: { tools: {} } }); + + mcpServer.registerTool('greet', { description: 'Greets' }, () => ({ + content: [{ type: 'text', text: 'hi' }] + })); + + const client = await connect(mcpServer); + + const result = await client.listTools(); + expect(result.tools.map(t => t.name)).toEqual(['greet']); + }); +}); + +describe('deterministic tools/list ordering (draft spec)', () => { + /*** + * Test: tools/list SHOULD return tools in a deterministic order when the underlying + * tool set has not changed. The SDK lists tools in registration (insertion) order. + */ + test('tools/list returns an identical order across repeated requests', async () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }); + + const names = ['zeta', 'alpha', 'mid', 'omega', 'beta']; + for (const name of names) { + mcpServer.registerTool(name, { inputSchema: z.object({ value: z.string() }) }, ({ value }) => ({ + content: [{ type: 'text', text: `${name}:${value}` }] + })); + } + + const client = await connect(mcpServer); + + const first = await client.listTools(); + const second = await client.listTools(); + + expect(first.tools.map(t => t.name)).toEqual(names); + expect(second.tools.map(t => t.name)).toEqual(names); + }); + + test('tools/list ordering stays stable across disable/enable toggles', async () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }); + + const names = ['zeta', 'alpha', 'mid', 'omega', 'beta']; + const registered = names.map(name => + mcpServer.registerTool(name, {}, () => ({ + content: [{ type: 'text', text: name }] + })) + ); + + const client = await connect(mcpServer); + + // Disable a tool in the middle: relative order of the remaining tools is unchanged. + registered[2].disable(); + const whileDisabled = await client.listTools(); + expect(whileDisabled.tools.map(t => t.name)).toEqual(['zeta', 'alpha', 'omega', 'beta']); + + // Re-enable it: the original insertion order is restored, not appended at the end. + registered[2].enable(); + const afterReenable = await client.listTools(); + expect(afterReenable.tools.map(t => t.name)).toEqual(names); + }); +}); From 78fbe2736d72be4841072b359b3a2b8c6f97cd5c Mon Sep 17 00:00:00 2001 From: Matt <77928207+mattzcarey@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:32:08 +0100 Subject: [PATCH 4/4] feat(core): reserved trace context _meta keys and propagation docs (SEP-414) (#2270) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/sep-414-trace-context-meta-keys.md | 5 + docs/client.md | 56 +++++++- docs/server.md | 30 ++++- examples/client/src/clientGuide.examples.ts | 55 +++++++- examples/server/src/serverGuide.examples.ts | 26 +++- packages/core/src/exports/public/index.ts | 5 +- packages/core/src/types/constants.ts | 38 ++++++ .../core/test/shared/traceContextMeta.test.ts | 120 ++++++++++++++++++ 8 files changed, 330 insertions(+), 5 deletions(-) create mode 100644 .changeset/sep-414-trace-context-meta-keys.md create mode 100644 packages/core/test/shared/traceContextMeta.test.ts 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 bfc13eeecd..c2bb5b05b1 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'; ``` @@ -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; [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 f8c325bd32..d03a6735a5 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'; ``` @@ -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 => { + // 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 55b3cfe7be..ec0be8986c 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 }); + }); +});