From 6aaa8380828781f284fe98a22067d56d64c0c3bb Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 1 Jun 2026 14:49:32 +0000 Subject: [PATCH 1/5] feat(core): expose the governing protocol version on the request handler context --- packages/client/src/client/client.ts | 10 ---- packages/core/src/shared/protocol.ts | 64 ++++++++++++++++++++++ packages/core/test/shared/protocol.test.ts | 53 +++++++++++++++++- 3 files changed, 116 insertions(+), 11 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 92a25cea0..673e8f764 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -225,7 +225,6 @@ export type ClientOptions = ProtocolOptions & { export class Client extends Protocol { private _serverCapabilities?: ServerCapabilities; private _serverVersion?: Implementation; - private _negotiatedProtocolVersion?: string; private _capabilities: ClientCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; @@ -554,15 +553,6 @@ export class Client extends Protocol { return this._serverVersion; } - /** - * After initialization has completed, this will be populated with the protocol version negotiated - * during the initialize handshake. When manually reconstructing a transport for reconnection, pass this - * value to the new transport so it continues sending the required `mcp-protocol-version` header. - */ - getNegotiatedProtocolVersion(): string | undefined { - return this._negotiatedProtocolVersion; - } - /** * After initialization has completed, this may be populated with information about the server's instructions. */ diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 361bd6fc7..1cac50a7b 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -9,6 +9,7 @@ import type { ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, + Implementation, JSONRPCErrorResponse, JSONRPCNotification, JSONRPCRequest, @@ -33,6 +34,7 @@ import type { TaskCreationParams } from '../types/index.js'; import { + DEFAULT_NEGOTIATED_PROTOCOL_VERSION, getNotificationSchema, getRequestSchema, getResultSchema, @@ -185,6 +187,18 @@ export type BaseContext = { */ method: string; + /** + * The protocol version governing this request. + * + * Resolved per request from the source the governing protocol revision defines: the initialize + * handshake for 2025-line revisions; the request's own `_meta` for revisions that carry a + * per-request envelope. Sources never mix and never fall back. + * + * For requests that arrive before the initialize handshake completes (where only `ping` is legal), + * this is the SDK's {@linkcode DEFAULT_NEGOTIATED_PROTOCOL_VERSION}. + */ + protocolVersion: string; + /** * Metadata from the original request. */ @@ -282,6 +296,32 @@ export type ServerContext = BaseContext & { */ closeStandaloneSSE?: () => void; }; + + /** + * Facts about the client that is calling this request. + * + * The values are resolved per request from the source the governing protocol revision defines: the + * initialize handshake for 2025-line revisions; the request's own `_meta` for revisions that carry a + * per-request envelope. Sources never mix and never fall back. For requests that arrive before the + * initialize handshake completes (where only `ping` is legal), `capabilities` is `{}` and `info` is + * `undefined`. + * + * Capabilities are declarations, not authorization: this exists so the server does not ask a client to + * do something it cannot (e.g. elicitation). It MUST NOT be used to gate access to tools, resources, or + * data — that is the authorization layer's job. + */ + client: { + /** + * The capabilities the calling client declared. `{}` before the initialize handshake completes. + */ + capabilities: ClientCapabilities; + + /** + * The calling client's implementation name and version, or `undefined` before the initialize + * handshake completes. + */ + info: Implementation | undefined; + }; }; /** @@ -323,6 +363,13 @@ export abstract class Protocol { protected _supportedProtocolVersions: string[]; + /** + * The protocol version negotiated for the current connection, set by the concrete role at its + * negotiation point (the client when it receives InitializeResult; the server when it responds + * to initialize), or `undefined` before negotiation has completed. + */ + protected _negotiatedProtocolVersion?: string; + /** * Callback for when the connection is closed for any reason. * @@ -408,6 +455,19 @@ export abstract class Protocol { */ protected abstract buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ContextT; + /** + * The protocol version negotiated for the current connection, or `undefined` before + * negotiation has completed. + * + * It is read per request when building the handler context to populate + * `ctx.mcpReq.protocolVersion`. On the client side, when manually reconstructing a transport for + * reconnection, pass this value to the new transport so it continues sending the required + * `mcp-protocol-version` header. + */ + getNegotiatedProtocolVersion(): string | undefined { + return this._negotiatedProtocolVersion; + } + private async _oncancel(notification: CancelledNotification): Promise { if (!notification.params.requestId) { return; @@ -607,6 +667,10 @@ export abstract class Protocol { mcpReq: { id: request.id, method: request.method, + // Resolved from the source the governing revision defines. At present that is the + // initialize-handshake-negotiated version (exposed by the concrete role); requests that + // arrive before the handshake completes (only `ping` is legal there) get the SDK default. + protocolVersion: this.getNegotiatedProtocolVersion() ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION, _meta: request.params?._meta, signal: abortController.signal, // BaseContext.mcpReq.send is declared with two overloads (spec-method-keyed and explicit-schema). Arrow diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index 619e09376..d58456a70 100644 --- a/packages/core/test/shared/protocol.test.ts +++ b/packages/core/test/shared/protocol.test.ts @@ -34,7 +34,7 @@ import type { Task, TaskCreationParams } from '../../src/types/index.js'; -import { ProtocolError, ProtocolErrorCode, RELATED_TASK_META_KEY } from '../../src/types/index.js'; +import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION, ProtocolError, ProtocolErrorCode, RELATED_TASK_META_KEY } from '../../src/types/index.js'; import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; // Test Protocol subclass for testing @@ -5678,3 +5678,54 @@ describe('TaskManager always present (NullTaskManager pattern)', () => { expect(mockClient.taskManager).toBe(mockTaskModule); }); }); + +describe('ctx.mcpReq.protocolVersion population', () => { + // Protocol subclass with a settable negotiated version, mirroring how Server/Client + // assign the inherited protected field at their negotiation points. + class NegotiatedVersionProtocol extends Protocol { + set negotiated(version: string | undefined) { + this._negotiatedProtocolVersion = version; + } + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } + } + + async function captureProtocolVersion(p: NegotiatedVersionProtocol, t: MockTransport): Promise { + await p.connect(t); + const captured = new Promise(resolve => { + p.setRequestHandler('ping', async (_request, ctx) => { + resolve(ctx.mcpReq.protocolVersion); + return {}; + }); + }); + t.onmessage?.({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} }); + return captured; + } + + test('falls back to the SDK default before the handshake completes', async () => { + const p = new NegotiatedVersionProtocol(); + // negotiated is undefined: this models a request (only ping is legal) arriving pre-initialize. + const version = await captureProtocolVersion(p, new MockTransport()); + expect(version).toBe(DEFAULT_NEGOTIATED_PROTOCOL_VERSION); + }); + + test('the base Protocol getter returns undefined so role-less subclasses get the default', async () => { + const p = createTestProtocol(); + const t = new MockTransport(); + await p.connect(t); + const captured = new Promise(resolve => { + p.setRequestHandler('ping', async (_request, ctx) => { + resolve(ctx.mcpReq.protocolVersion); + return {}; + }); + }); + t.onmessage?.({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} }); + expect(await captured).toBe(DEFAULT_NEGOTIATED_PROTOCOL_VERSION); + }); +}); From 0966d2bf6aa521b3547141a59b1390f6bd8db5ca Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 1 Jun 2026 14:49:32 +0000 Subject: [PATCH 2/5] feat(server): expose per-request client capabilities and info on the handler context --- packages/server/src/server/server.ts | 20 +++--- packages/server/test/server/server.test.ts | 75 ++++++++++++++++++++++ 2 files changed, 85 insertions(+), 10 deletions(-) diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 45ee84eb6..50081358e 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -98,7 +98,6 @@ export type ServerOptions = ProtocolOptions & { export class Server extends Protocol { private _clientCapabilities?: ClientCapabilities; private _clientVersion?: Implementation; - private _negotiatedProtocolVersion?: string; private _capabilities: ServerCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; @@ -164,6 +163,12 @@ export class Server extends Protocol { elicitInput: (params, options) => this.elicitInput(params, options), requestSampling: (params, options) => this.createMessage(params, options) }, + // Sourced from the handshake state retained at initialize - the only source that exists today. + // Before the handshake completes (only `ping` is legal there), capabilities is `{}` and info undefined. + client: { + capabilities: this._clientCapabilities ?? {}, + info: this._clientVersion + }, http: hasHttpInfo ? { ...ctx.http, @@ -442,6 +447,8 @@ export class Server extends Protocol { /** * After initialization has completed, this will be populated with the client's reported capabilities. + * + * Inside a request handler, prefer `ctx.client.capabilities`, which reads the same facts per request. */ getClientCapabilities(): ClientCapabilities | undefined { return this._clientCapabilities; @@ -449,20 +456,13 @@ export class Server extends Protocol { /** * After initialization has completed, this will be populated with information about the client's name and version. + * + * Inside a request handler, prefer `ctx.client.info`, which reads the same facts per request. */ getClientVersion(): Implementation | undefined { return this._clientVersion; } - /** - * After initialization has completed, this will be populated with the protocol version negotiated - * with the client (the version the server responded with during the initialize handshake), or - * `undefined` before initialization. - */ - getNegotiatedProtocolVersion(): string | undefined { - return this._negotiatedProtocolVersion; - } - /** * Returns the current server capabilities. */ diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index 0edcfd3af..36016a093 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -1,5 +1,7 @@ import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'; +import type { ClientCapabilities, Implementation, ServerContext } from '@modelcontextprotocol/core'; import { + DEFAULT_NEGOTIATED_PROTOCOL_VERSION, InitializeResultSchema, InMemoryTransport, isJSONRPCResultResponse, @@ -130,4 +132,77 @@ describe('Server', () => { await server.close(); }); }); + + describe('ctx.client / ctx.mcpReq.protocolVersion on the handler context', () => { + /** + * Connects the server, registers a ping handler that captures its ServerContext, drives the + * initialize handshake (with the given client capabilities/info), then sends a ping so the + * handler runs. Returns the captured context. + */ + async function captureContextAfterInitialize( + server: Server, + requestedVersion: string, + clientCapabilities: ClientCapabilities, + clientInfo: Implementation + ): Promise { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + + let captured: ServerContext | undefined; + server.setRequestHandler('ping', async (_request, ctx) => { + captured = ctx; + return {}; + }); + + await clientTransport.start(); + + const initResponse = new Promise(resolve => { + clientTransport.onmessage = () => resolve(); + }); + await clientTransport.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: requestedVersion, capabilities: clientCapabilities, clientInfo } + } as JSONRPCMessage); + await initResponse; + + const pingResponse = new Promise(resolve => { + clientTransport.onmessage = () => resolve(); + }); + await clientTransport.send({ jsonrpc: '2.0', id: 2, method: 'ping', params: {} } as JSONRPCMessage); + await pingResponse; + + if (!captured) { + throw new Error('ping handler did not run'); + } + return captured; + } + + it('pre-initialize: ping before the handshake gets {} capabilities, undefined info, and the default version', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + + let captured: ServerContext | undefined; + server.setRequestHandler('ping', async (_request, ctx) => { + captured = ctx; + return {}; + }); + + await clientTransport.start(); + const pingResponse = new Promise(resolve => { + clientTransport.onmessage = () => resolve(); + }); + // No initialize handshake first - only ping is legal pre-initialize. + await clientTransport.send({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} } as JSONRPCMessage); + await pingResponse; + + expect(captured?.client.capabilities).toEqual({}); + expect(captured?.client.info).toBeUndefined(); + expect(captured?.mcpReq.protocolVersion).toBe(DEFAULT_NEGOTIATED_PROTOCOL_VERSION); + + await server.close(); + }); + }); }); From 97cf203b4625b6bc55ebdecb1abd6e3dc10f2d07 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 1 Jun 2026 14:52:30 +0000 Subject: [PATCH 3/5] test(e2e): cover the per-request envelope on the handler context --- test/e2e/requirements.ts | 14 ++ test/e2e/scenarios/handler-context.test.ts | 150 ++++++++++++++++++++- 2 files changed, 162 insertions(+), 2 deletions(-) diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 092abb1a7..fe1d111f4 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -114,6 +114,20 @@ export const REQUIREMENTS: Record = { transports: STATEFUL_TRANSPORTS, note: 'Under stateless hosting each request is served by a new server instance, so state set up earlier in the session cannot be observed.' }, + 'protocol:envelope:ctx-version-readable': { + source: 'sdk', + behavior: + 'A request handler can read the protocol version governing the current request from its context (ctx.mcpReq.protocolVersion); on a 2025 connection it equals the initialize-negotiated version.', + transports: STATEFUL_TRANSPORTS, + note: "Groundwork for SEP-2575's per-request envelope. Today the version is sourced from the initialize handshake, so a stateless host (fresh server per request, no retained handshake) would read the pre-initialize default rather than the negotiated version." + }, + 'protocol:envelope:ctx-capabilities-readable': { + source: 'sdk', + behavior: + "A server request handler can read the calling client's declared capabilities and implementation info from its context (ctx.client.capabilities / ctx.client.info); on a 2025 connection these equal what the client sent at initialize.", + transports: STATEFUL_TRANSPORTS, + note: 'Per-request counterpart of Server.getClientCapabilities()/getClientVersion(); under the 2026 revision (no handshake) the per-request form becomes the only way to read these. The structural supersession is recorded when 2026 connections become possible. Stateless hosting serves each request from a fresh server that never processed initialize, so the handshake-sourced facts are not observable there.' + }, // Protocol primitives: cancellation, timeout, progress, errors, _meta diff --git a/test/e2e/scenarios/handler-context.test.ts b/test/e2e/scenarios/handler-context.test.ts index 81c5a776b..cf6e0e336 100644 --- a/test/e2e/scenarios/handler-context.test.ts +++ b/test/e2e/scenarios/handler-context.test.ts @@ -10,11 +10,25 @@ */ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import type { CreateMessageRequest, ElicitRequest, ElicitRequestFormParams, LoggingLevel } from '@modelcontextprotocol/server'; -import { McpServer } from '@modelcontextprotocol/server'; +import type { + ClientCapabilities, + CreateMessageRequest, + ElicitRequest, + ElicitRequestFormParams, + Implementation, + LoggingLevel +} from '@modelcontextprotocol/server'; +import { LATEST_PROTOCOL_VERSION, McpServer, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server'; import { expect, vi } from 'vitest'; import { z } from 'zod/v4'; +/** A supported protocol version other than the latest, used to prove the version reported is the negotiated one. */ +const OLDER_SUPPORTED_VERSION = (() => { + const older = SUPPORTED_PROTOCOL_VERSIONS.find(v => v !== LATEST_PROTOCOL_VERSION); + if (older === undefined) throw new Error('expected SUPPORTED_PROTOCOL_VERSIONS to include a version other than the latest'); + return older; +})(); + import { hostPerSession, wire } from '../helpers/index.js'; import { verifies } from '../helpers/verifies.js'; import type { TestArgs } from '../types.js'; @@ -165,3 +179,135 @@ verifies('hosting:context:web-request-headers', async (_args: TestArgs) => { await mcpHost.close(); } }); + +verifies( + 'protocol:envelope:ctx-version-readable', + async ({ transport }: TestArgs) => { + let seenVersion: string | undefined; + const makeServer = () => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool('read-version', { inputSchema: z.object({}) }, (_args, ctx) => { + seenVersion = ctx.mcpReq.protocolVersion; + return { content: [{ type: 'text', text: ctx.mcpReq.protocolVersion }] }; + }); + return s; + }; + const client = new Client({ name: 'c', version: '0' }); + + await using _ = await wire(transport, makeServer, client); + const result = await client.callTool({ name: 'read-version', arguments: {} }); + + // On a 2025 connection the governing version is the one negotiated at initialize. + expect(seenVersion).toBe(client.getNegotiatedProtocolVersion()); + expect(result.content).toEqual([{ type: 'text', text: client.getNegotiatedProtocolVersion() }]); + }, + { title: 'server handler reads negotiated version (default)' } +); + +verifies( + 'protocol:envelope:ctx-version-readable', + async ({ transport }: TestArgs) => { + let seenVersion: string | undefined; + const makeServer = () => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool('read-version', { inputSchema: z.object({}) }, (_args, ctx) => { + seenVersion = ctx.mcpReq.protocolVersion; + return { content: [{ type: 'text', text: ctx.mcpReq.protocolVersion }] }; + }); + return s; + }; + // Pin the client to an older supported version so the governing version differs from the latest, + // proving the handler reads the actually-negotiated version rather than a constant. + const client = new Client({ name: 'c', version: '0' }, { supportedProtocolVersions: [OLDER_SUPPORTED_VERSION] }); + + await using _ = await wire(transport, makeServer, client); + await client.callTool({ name: 'read-version', arguments: {} }); + + expect(seenVersion).toBe(OLDER_SUPPORTED_VERSION); + expect(client.getNegotiatedProtocolVersion()).toBe(OLDER_SUPPORTED_VERSION); + }, + { title: 'server handler reads negotiated version (pinned older)' } +); + +verifies( + 'protocol:envelope:ctx-version-readable', + async ({ transport }: TestArgs) => { + // BaseContext is shared by both roles: a client-side handler can read the governing version too. + let seenByClientHandler: string | undefined; + const makeServer = () => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool('ask', { inputSchema: z.object({}) }, async (_args, ctx) => { + const r = await ctx.mcpReq.requestSampling({ + messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], + maxTokens: 10 + }); + const text = !Array.isArray(r.content) && r.content.type === 'text' ? r.content.text : ''; + return { content: [{ type: 'text', text }] }; + }); + return s; + }; + const client = new Client({ name: 'c', version: '0' }, { capabilities: { sampling: {} } }); + client.setRequestHandler('sampling/createMessage', async (_req, ctx) => { + seenByClientHandler = ctx.mcpReq.protocolVersion; + return { model: 'stub', role: 'assistant', stopReason: 'endTurn', content: { type: 'text', text: 'ok' } }; + }); + + await using _ = await wire(transport, makeServer, client); + await client.callTool({ name: 'ask', arguments: {} }); + + expect(seenByClientHandler).toBe(client.getNegotiatedProtocolVersion()); + }, + { title: 'client handler reads governing version' } +); + +verifies( + 'protocol:envelope:ctx-capabilities-readable', + async ({ transport }: TestArgs) => { + let seen: { capabilities: ClientCapabilities; info: Implementation | undefined } | undefined; + const makeServer = () => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool('read-client', { inputSchema: z.object({}) }, (_args, ctx) => { + seen = { capabilities: ctx.client.capabilities, info: ctx.client.info }; + return { content: [{ type: 'text', text: 'ok' }] }; + }); + return s; + }; + const declared: ClientCapabilities = { sampling: {}, roots: { listChanged: true } }; + const clientInfo: Implementation = { name: 'declaring-client', version: '4.5.6' }; + const client = new Client(clientInfo, { capabilities: declared }); + + await using _ = await wire(transport, makeServer, client); + await client.callTool({ name: 'read-client', arguments: {} }); + + // The handler sees exactly what the client declared at initialize. + expect(seen?.capabilities.sampling).toEqual({}); + expect(seen?.capabilities.roots).toEqual({ listChanged: true }); + expect(seen?.info).toEqual(clientInfo); + }, + { title: 'declared capabilities and info' } +); + +verifies( + 'protocol:envelope:ctx-capabilities-readable', + async ({ transport }: TestArgs) => { + let seen: { capabilities: ClientCapabilities; info: Implementation | undefined } | undefined; + const makeServer = () => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool('read-client', { inputSchema: z.object({}) }, (_args, ctx) => { + seen = { capabilities: ctx.client.capabilities, info: ctx.client.info }; + return { content: [{ type: 'text', text: 'ok' }] }; + }); + return s; + }; + // A client that declares no optional capabilities yields a `{}`-shaped object, not undefined. + const clientInfo: Implementation = { name: 'bare-client', version: '0.0.1' }; + const client = new Client(clientInfo); + + await using _ = await wire(transport, makeServer, client); + await client.callTool({ name: 'read-client', arguments: {} }); + + expect(seen?.capabilities).toEqual({}); + expect(seen?.info).toEqual(clientInfo); + }, + { title: 'no optional capabilities yields {} shape' } +); From dbab53eca78683f9b7f7dbaac0cfd365858748aa Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 1 Jun 2026 16:43:34 +0000 Subject: [PATCH 4/5] docs: document the per-request envelope on the handler context --- docs/client.md | 17 ++++++++ docs/migration.md | 5 ++- docs/server.md | 45 +++++++++++++++++++++ examples/client/src/clientGuide.examples.ts | 17 ++++++++ examples/server/src/serverGuide.examples.ts | 41 +++++++++++++++++++ 5 files changed, 124 insertions(+), 1 deletion(-) diff --git a/docs/client.md b/docs/client.md index 0946eeec9..56c9e43d1 100644 --- a/docs/client.md +++ b/docs/client.md @@ -487,6 +487,23 @@ client.setRequestHandler('roots/list', async () => { When the available roots change, notify the server with {@linkcode @modelcontextprotocol/client!client/client.Client#sendRootsListChanged | client.sendRootsListChanged()}. +### Request context + +Handlers receive the request context (`ctx`) as their second argument. `ctx.mcpReq.protocolVersion` (from {@linkcode @modelcontextprotocol/client!index.BaseContext | BaseContext}) is the protocol version governing the request: + +```ts source="../examples/client/src/clientGuide.examples.ts#requestContext_handler" +client.setRequestHandler('sampling/createMessage', async (request, ctx) => { + console.log(`Sampling request under MCP ${ctx.mcpReq.protocolVersion}:`, request.params.messages.at(-1)); + + // In production, send messages to your LLM here + return { + model: 'my-model', + role: 'assistant' as const, + content: { type: 'text' as const, text: 'Response from the model' } + }; +}); +``` + ## Error handling ### Tool errors vs protocol errors diff --git a/docs/migration.md b/docs/migration.md index e6faa905c..5e7df2c2a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -602,6 +602,8 @@ The `RequestHandlerExtra` type has been replaced with a structured context type | `extra.taskStore` | `ctx.task?.store` | | `extra.taskId` | `ctx.task?.id` | | `extra.taskRequestedTtl` | `ctx.task?.requestedTtl` | +| — (new in v2) | `ctx.mcpReq.protocolVersion` | +| — (new in v2) | `ctx.client.capabilities`, `ctx.client.info` (only on `ServerContext`) | **Before (v1):** @@ -627,9 +629,10 @@ server.setRequestHandler('tools/call', async (request, ctx) => { Context fields are organized into 4 groups: -- **`mcpReq`** — request-level concerns: `id`, `method`, `_meta`, `signal`, `send()`, `notify()`, plus server-only `log()`, `elicitInput()`, and `requestSampling()` +- **`mcpReq`** — request-level concerns: `id`, `method`, `protocolVersion`, `_meta`, `signal`, `send()`, `notify()`, plus server-only `log()`, `elicitInput()`, and `requestSampling()` - **`http?`** — HTTP transport concerns (undefined for stdio): `authInfo`, plus server-only `req`, `closeSSE`, `closeStandaloneSSE` - **`task?`** — task lifecycle: `id`, `store`, `requestedTtl` +- **`client`** — server-only: the calling client's declared `capabilities` and implementation `info` `BaseContext` is the common base type shared by both `ServerContext` and `ClientContext`. `ServerContext` extends each group with server-specific additions via type intersection. diff --git a/docs/server.md b/docs/server.md index 3b173af4e..996bf353a 100644 --- a/docs/server.md +++ b/docs/server.md @@ -495,6 +495,51 @@ server.registerTool( ); ``` +## Reading request context + +Every handler receives the request context (`ctx`) as its second argument. Beyond the helpers shown above, it carries per-request facts about the caller: + +- `ctx.mcpReq.protocolVersion` (from {@linkcode @modelcontextprotocol/server!index.BaseContext | BaseContext}) — the protocol version governing the request. +- `ctx.client.capabilities` and `ctx.client.info` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) — the calling client's declared capabilities and implementation info. + +Check `ctx.client.capabilities` before sending a [server-initiated request](#server-initiated-requests) so you never ask a client to do something it cannot — for example, only [elicit input](#elicitation) when the client declared the `elicitation` capability: + +```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_requestContext" +server.registerTool( + 'delete-records', + { + description: 'Delete records, asking for confirmation when the client supports it', + inputSchema: z.object({ table: z.string() }) + }, + async ({ table }, ctx): Promise => { + // Per-request facts: the calling client and the protocol version governing this request + const caller = `${ctx.client.info?.name ?? 'unknown client'} (MCP ${ctx.mcpReq.protocolVersion})`; + + // Only ask for confirmation if the calling client declared the elicitation capability + if (ctx.client.capabilities.elicitation) { + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: `Delete all records in ${table}?`, + requestedSchema: { + type: 'object', + properties: { confirm: { type: 'boolean', title: 'Confirm' } }, + required: ['confirm'] + } + }); + if (result.action !== 'accept' || result.content?.confirm !== true) { + return { content: [{ type: 'text', text: 'Deletion cancelled.' }] }; + } + } + + // ... delete records, attributing the request to `caller` ... + return { content: [{ type: 'text', text: `Deleted all records in ${table} (requested by ${caller})` }] }; + } +); +``` + +> [!IMPORTANT] +> Capabilities are declarations, not authorization. Never use them to gate access to tools, resources, or data — that is the authorization layer's job. + ## Tasks (experimental) > [!WARNING] diff --git a/examples/client/src/clientGuide.examples.ts b/examples/client/src/clientGuide.examples.ts index 9704ed8a5..d6d983e9b 100644 --- a/examples/client/src/clientGuide.examples.ts +++ b/examples/client/src/clientGuide.examples.ts @@ -437,6 +437,22 @@ function roots_handler(client: Client) { //#endregion roots_handler } +/** Example: Read the governing protocol version from the handler context. */ +function requestContext_handler(client: Client) { + //#region requestContext_handler + client.setRequestHandler('sampling/createMessage', async (request, ctx) => { + console.log(`Sampling request under MCP ${ctx.mcpReq.protocolVersion}:`, request.params.messages.at(-1)); + + // In production, send messages to your LLM here + return { + model: 'my-model', + role: 'assistant' as const, + content: { type: 'text' as const, text: 'Response from the model' } + }; + }); + //#endregion requestContext_handler +} + // --------------------------------------------------------------------------- // Error handling // --------------------------------------------------------------------------- @@ -568,6 +584,7 @@ void capabilities_declaration; void sampling_handler; void elicitation_handler; void roots_handler; +void requestContext_handler; void errorHandling_toolErrors; void errorHandling_lifecycle; void errorHandling_timeout; diff --git a/examples/server/src/serverGuide.examples.ts b/examples/server/src/serverGuide.examples.ts index 5a4712f83..2a746d1a2 100644 --- a/examples/server/src/serverGuide.examples.ts +++ b/examples/server/src/serverGuide.examples.ts @@ -419,6 +419,46 @@ function registerTool_roots(server: McpServer) { //#endregion registerTool_roots } +// --------------------------------------------------------------------------- +// Reading request context +// --------------------------------------------------------------------------- + +/** Example: Tool that reads per-request facts (protocol version, client capabilities) from the handler context. */ +function registerTool_requestContext(server: McpServer) { + //#region registerTool_requestContext + server.registerTool( + 'delete-records', + { + description: 'Delete records, asking for confirmation when the client supports it', + inputSchema: z.object({ table: z.string() }) + }, + async ({ table }, ctx): Promise => { + // Per-request facts: the calling client and the protocol version governing this request + const caller = `${ctx.client.info?.name ?? 'unknown client'} (MCP ${ctx.mcpReq.protocolVersion})`; + + // Only ask for confirmation if the calling client declared the elicitation capability + if (ctx.client.capabilities.elicitation) { + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: `Delete all records in ${table}?`, + requestedSchema: { + type: 'object', + properties: { confirm: { type: 'boolean', title: 'Confirm' } }, + required: ['confirm'] + } + }); + if (result.action !== 'accept' || result.content?.confirm !== true) { + return { content: [{ type: 'text', text: 'Deletion cancelled.' }] }; + } + } + + // ... delete records, attributing the request to `caller` ... + return { content: [{ type: 'text', text: `Deleted all records in ${table} (requested by ${caller})` }] }; + } + ); + //#endregion registerTool_requestContext +} + // --------------------------------------------------------------------------- // Transports // --------------------------------------------------------------------------- @@ -546,6 +586,7 @@ void registerTool_progress; void registerTool_sampling; void registerTool_elicitation; void registerTool_roots; +void registerTool_requestContext; void registerResource_static; void registerResource_template; void registerPrompt_basic; From e04764889542d671a79214f8383c82ddcf0f85ea Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 1 Jun 2026 21:48:48 +0000 Subject: [PATCH 5/5] Add changeset for the per-request envelope context fields --- .changeset/per-request-envelope-on-handler-context.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/per-request-envelope-on-handler-context.md diff --git a/.changeset/per-request-envelope-on-handler-context.md b/.changeset/per-request-envelope-on-handler-context.md new file mode 100644 index 000000000..727bd34e8 --- /dev/null +++ b/.changeset/per-request-envelope-on-handler-context.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/server': patch +--- + +Request handlers can now read the protocol version governing the current request from their context (`ctx.mcpReq.protocolVersion`, both client and server handlers), and server handlers can read the calling client's declared capabilities and implementation info +(`ctx.client.capabilities`, `ctx.client.info`). `getNegotiatedProtocolVersion()` is now declared on `Protocol`, so both roles expose it.