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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/per-request-envelope-on-handler-context.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):**

Expand All @@ -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.

Expand Down
45 changes: 45 additions & 0 deletions docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<CallToolResult> => {
// 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]
Expand Down
17 changes: 17 additions & 0 deletions examples/client/src/clientGuide.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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;
Expand Down
41 changes: 41 additions & 0 deletions examples/server/src/serverGuide.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,46 @@
//#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<CallToolResult> => {
// 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: {

Check warning on line 444 in examples/server/src/serverGuide.examples.ts

View check run for this annotation

Claude / Claude Code Review

Docs example gates form elicitation on the wrong capability check

The new `registerTool_requestContext` example (and its synced copy in docs/server.md) gates the form-mode `elicitInput` call on `ctx.client.capabilities.elicitation` being truthy, but `Server.elicitInput()` with `mode: 'form'` requires the mode-specific `elicitation.form` capability and throws `CapabilityNotSupported` otherwise — so a url-only client (`elicitation: { url: {} }`) passes the example's guard yet the tool call returns `isError`, the opposite of the documented "never ask a client to
Comment on lines +439 to +444
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new registerTool_requestContext example (and its synced copy in docs/server.md) gates the form-mode elicitInput call on ctx.client.capabilities.elicitation being truthy, but Server.elicitInput() with mode: 'form' requires the mode-specific elicitation.form capability and throws CapabilityNotSupported otherwise — so a url-only client (elicitation: { url: {} }) passes the example's guard yet the tool call returns isError, the opposite of the documented "never ask a client to do something it cannot" intent. Change the guard to ctx.client.capabilities.elicitation?.form to match the predicate the SDK actually enforces.

Extended reasoning...

What the bug is. The new "Reading request context" section exists specifically to teach capability-aware gating using the new ctx.client.capabilities field: "Check ctx.client.capabilities before sending a server-initiated request so you never ask a client to do something it cannot." The example then guards a form-mode elicitation with the coarse check if (ctx.client.capabilities.elicitation) (examples/server/src/serverGuide.examples.ts ~line 441, synced into docs/server.md). However, the SDK's own predicate for form elicitation is stricter: Server.elicitInput() with mode: 'form' throws SdkError(CapabilityNotSupported, 'Client does not support form elicitation.') unless this._clientCapabilities?.elicitation?.form is set (packages/server/src/server/server.ts:579-580).

The code path that triggers it. A spec-valid client may declare url-only elicitation: capabilities: { elicitation: { url: {} } }. ElicitationCapabilitySchema only normalizes an empty elicitation: {} object to { form: {} } (packages/core/src/types/schemas.ts:318-332); a non-empty, form-less declaration like { url: {} } is preserved as-is. The client-side helper getSupportedElicitationModes() in packages/client/src/client/client.ts agrees: it returns supportsFormMode: false for a url-only declaration, so such a client will also reject an incoming form request. Both ends therefore treat "has elicitation" and "supports form elicitation" as different things — but the example conflates them.

Step-by-step proof.

  1. Client connects with capabilities: { elicitation: { url: {} } }. After initialize, ctx.client.capabilities.elicitation is { url: {} } — truthy.
  2. The client calls the example's delete-records tool.
  3. The handler evaluates if (ctx.client.capabilities.elicitation) → truthy object → enters the branch.
  4. ctx.mcpReq.elicitInput({ mode: 'form', ... }) calls Server.elicitInput(), which checks this._clientCapabilities?.elicitation?.formundefined → throws SdkError(CapabilityNotSupported, 'Client does not support form elicitation.').
  5. The McpServer.registerTool wrapper catches the throw and converts it into an isError: true tool result. The records are never deleted and the user never sees a confirmation prompt — the tool just fails, which is exactly the "asking a client to do something it cannot" failure the section says this pattern prevents.

Why nothing else prevents it. The example never reaches the elicitation request handler on the client (the SDK throws server-side first), and there is no fallback path in the example: the throw escapes the handler before the "delete and attribute to caller" branch. The e2e scenarios added in this PR only cover { sampling, roots } and bare-capability clients, so the url-only-elicitation shape is not exercised.

Impact. This is a documentation/example correctness issue only — no SDK runtime code is wrong. But because the example is the canonical illustration of the new ctx.client.capabilities field, it teaches readers a check that does not match what the SDK enforces, and copy-pasted handlers will fail for the (narrow but legitimate) class of url-only elicitation clients. Note that the bare elicitation: {} case is fine thanks to the schema preprocess normalization, so the impact is limited to form-less, non-empty elicitation declarations.

How to fix. Change the guard in examples/server/src/serverGuide.examples.ts (and re-run the snippet sync so docs/server.md follows) to the mode-specific check:

if (ctx.client.capabilities.elicitation?.form) {

This matches the predicate Server.elicitInput() enforces for form mode and the semantics of getSupportedElicitationModes(). Optionally, the surrounding prose ("only elicit input when the client declared the elicitation capability") could mention that form vs. url elicitation are gated by their mode-specific sub-capabilities.

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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -546,6 +586,7 @@
void registerTool_sampling;
void registerTool_elicitation;
void registerTool_roots;
void registerTool_requestContext;
void registerResource_static;
void registerResource_template;
void registerPrompt_basic;
Expand Down
10 changes: 0 additions & 10 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,6 @@ export type ClientOptions = ProtocolOptions & {
export class Client extends Protocol<ClientContext> {
private _serverCapabilities?: ServerCapabilities;
private _serverVersion?: Implementation;
private _negotiatedProtocolVersion?: string;
private _capabilities: ClientCapabilities;
private _instructions?: string;
private _jsonSchemaValidator: jsonSchemaValidator;
Expand Down Expand Up @@ -554,15 +553,6 @@ export class Client extends Protocol<ClientContext> {
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.
*/
Expand Down
64 changes: 64 additions & 0 deletions packages/core/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
ElicitRequestFormParams,
ElicitRequestURLParams,
ElicitResult,
Implementation,
JSONRPCErrorResponse,
JSONRPCNotification,
JSONRPCRequest,
Expand All @@ -33,6 +34,7 @@ import type {
TaskCreationParams
} from '../types/index.js';
import {
DEFAULT_NEGOTIATED_PROTOCOL_VERSION,
getNotificationSchema,
getRequestSchema,
getResultSchema,
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
};
};

/**
Expand Down Expand Up @@ -323,6 +363,13 @@ export abstract class Protocol<ContextT extends BaseContext> {

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.
*
Expand Down Expand Up @@ -408,6 +455,19 @@ export abstract class Protocol<ContextT extends BaseContext> {
*/
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<void> {
if (!notification.params.requestId) {
return;
Expand Down Expand Up @@ -607,6 +667,10 @@ export abstract class Protocol<ContextT extends BaseContext> {
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
Expand Down
53 changes: 52 additions & 1 deletion packages/core/test/shared/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<BaseContext> {
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<string> {
await p.connect(t);
const captured = new Promise<string>(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<string>(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);
});
});
Loading
Loading