Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/post-dispatch-32021-http-400.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/server': patch
'@modelcontextprotocol/core-internal': patch
---

Return HTTP 400 for a `MissingRequiredClientCapabilityError` (`-32021`) produced after dispatch. The spec mandates `400 Bad Request` for this error with no condition on where it arose, but only the pre-dispatch capability gate honored that; the post-handler emission — the `input_required` gate rejecting an embedded request whose required capability the caller did not declare — surfaced in-band on HTTP 200. The JSON-RPC error body is unchanged, every other error code (including a handler relaying a downstream peer's `-32020`/`-32022`) keeps the origin-keyed in-band behavior, and the mapping only applies while the response is uncommitted: an exchange that already streamed — or one hosted with `responseMode: 'sse'`, which opens its stream at dispatch end — keeps its committed 200 and carries the error in-stream.
12 changes: 12 additions & 0 deletions docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,18 @@ fetch-shaped handler.
> `toNodeHandler(handler)` and add the `@modelcontextprotocol/node` import.
> `NodeIncomingMessageLike` / `NodeServerResponseLike` are now exported from
> `@modelcontextprotocol/node`, not `@modelcontextprotocol/server`.
>
> Also: a `MissingRequiredClientCapabilityError` (`-32021`) produced **after** dispatch
> — the `input_required` gate refusing an embedded request whose capability the caller
> did not declare — now answers HTTP **400** (earlier alphas surfaced it in-band on
> 200). The spec mandates 400 for this error wherever it arises; the JSON-RPC body is
> unchanged. This applies to a handler-thrown `-32021` too: a proxy relaying a
> downstream server's `-32021` should translate it (its `requiredCapabilities`
> describes the downstream hop's envelope) rather than rethrow the bare error. Every
> other handler-produced code (including a relayed `-32020`/`-32022`)
> keeps the in-band 200, and an exchange whose response stream is already open — the
> handler streamed first, or `responseMode: 'sse'` — keeps its committed 200 and
> carries the error in-stream.

### Server over stdio / long-lived connections: `serveStdio`

Expand Down
23 changes: 19 additions & 4 deletions packages/core-internal/src/shared/inboundClassification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,9 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor
* the ladder (or a pre-handler protocol gate) produced. Errors produced by
* request handlers — whatever their code — stay in-band on HTTP 200, and are
* never mapped to an HTTP status by this table; in particular `-32603` and
* domain-specific codes never become a blanket 500.
* domain-specific codes never become a blanket 500. The single exception is
* `MissingRequiredClientCapability` (-32021) — see
* {@linkcode httpStatusForErrorCode}.
*
* `-32602` (invalid params) deliberately has NO entry: the only invalid-params
* rejection that maps to HTTP 400 is the classifier's own envelope rung
Expand All @@ -390,11 +392,24 @@ export const LADDER_ERROR_HTTP_STATUS: Readonly<Record<number, number>> = {
/**
* The HTTP status to answer a JSON-RPC error with, keyed on the error's
* origin. `in-band` errors (anything produced by a request handler) are
* always HTTP 200 — the JSON-RPC error response is the payload, not an HTTP
* failure. `ladder` errors map through {@linkcode LADDER_ERROR_HTTP_STATUS}.
* HTTP 200 — the JSON-RPC error response is the payload, not an HTTP
* failure — with ONE exception: `MissingRequiredClientCapability` (-32021),
* whose 400 the spec mandates on the error itself with no origin condition,
* and which the SDK genuinely produces after dispatch (the `input_required`
* capability gate). A handler relaying some downstream peer's `-32020`/`-32022`
* is NOT that peer's spec error and stays in-band like every other handler
* code. `ladder` errors map through {@linkcode LADDER_ERROR_HTTP_STATUS}.
*
* The per-request transport intentionally does NOT delegate to this function:
* its `?? 400` ladder fallback is only correct for entry-gate codes known to
* the table, and would wrongly map dispatch-window errors outside it (a
* window `-32602` must stay in-band on 200). The transport indexes the table
* directly; keep the two in agreement when editing either.
*/
export function httpStatusForErrorCode(code: number, origin: 'ladder' | 'in-band'): number {
if (origin === 'in-band') return 200;
if (origin === 'in-band') {
return code === ProtocolErrorCode.MissingRequiredClientCapability ? 400 : 200;
}
return LADDER_ERROR_HTTP_STATUS[code] ?? 400;
}

Expand Down
41 changes: 31 additions & 10 deletions packages/core-internal/test/shared/errorHttpStatusMatrix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@
*
* - errors produced by the validation ladder or a pre-handler protocol gate
* map through the table (`-32601` → 404; the small mandated 400 set);
* - everything a request handler produces — whatever its code, including
* `-32603`, `-32602` and domain-specific codes — stays in-band on HTTP 200,
* never a blanket 500;
* - everything a request handler produces — including `-32603`, `-32602` and
* domain-specific codes — stays in-band on HTTP 200, never a blanket 500;
* - EXCEPT `-32021` (MissingRequiredClientCapability): the spec mandates its
* 400 per-error with no origin condition, and the `input_required`
* capability gate genuinely emits it after dispatch — so it alone is
* status-mapped wherever it arises. A handler relaying a downstream peer's
* `-32020`/`-32022` is not that peer's spec error and stays in-band;
* - `-32602` deliberately has no table entry: the classifier's envelope rung
* carries its own HTTP 400 and is the only invalid-params rejection that
* maps to 400.
Expand Down Expand Up @@ -45,7 +49,7 @@ describe('the status matrix — pinned cells', () => {
expect(httpStatusForErrorCode(row.code, 'ladder')).toBe(row.status);
});

test('every code stays in-band on HTTP 200 when handler-originated — including internal errors and domain codes', () => {
test('every code except -32021 stays in-band on HTTP 200 when handler-originated — including internal errors and domain codes', () => {
const handlerCodes = [
ProtocolErrorCode.InternalError,
ProtocolErrorCode.InvalidParams,
Expand All @@ -61,6 +65,16 @@ describe('the status matrix — pinned cells', () => {
}
});

test('-32021 is the single code-keyed exception: its spec-mandated 400 applies wherever it arises', () => {
expect(httpStatusForErrorCode(ProtocolErrorCode.MissingRequiredClientCapability, 'in-band')).toBe(400);
expect(httpStatusForErrorCode(ProtocolErrorCode.MissingRequiredClientCapability, 'ladder')).toBe(400);
// The relay contract for the OTHER two spec-defined HTTP errors is
// origin-keyed: a handler-relayed -32020/-32022 is not this server's
// spec error and stays in-band.
expect(httpStatusForErrorCode(HEADER_MISMATCH_ERROR_CODE, 'in-band')).toBe(200);
expect(httpStatusForErrorCode(ProtocolErrorCode.UnsupportedProtocolVersion, 'in-band')).toBe(200);
});

test('-32603 never becomes a blanket 500: handler-originated internal errors are in-band', () => {
expect(LADDER_ERROR_HTTP_STATUS[ProtocolErrorCode.InternalError]).toBeUndefined();
expect(httpStatusForErrorCode(ProtocolErrorCode.InternalError, 'in-band')).toBe(200);
Expand All @@ -71,12 +85,19 @@ describe('the status matrix — pinned cells', () => {
expect(httpStatusForErrorCode(ProtocolErrorCode.InvalidParams, 'in-band')).toBe(200);
});

test('the table is exactly the mandated set (no silent growth)', () => {
expect(
Object.keys(LADDER_ERROR_HTTP_STATUS)
.map(Number)
.sort((a, b) => a - b)
).toEqual([-32_700, -32_601, -32_600, -32_022, -32_021, -32_020].sort((a, b) => a - b));
test('the table is exactly the mandated set, keys and values (no silent growth)', () => {
// The parse-error and invalid-request rows joined the table when the
// status matrix was completed alongside the cache fill / capability
// gate work; they were previously carried only by the classifier's own
// httpStatus on the rejection outcomes (same 400, now table-visible).
expect(LADDER_ERROR_HTTP_STATUS).toEqual({
[-32_700]: 400,
[-32_601]: 404,
[-32_600]: 400,
[-32_022]: 400,
[-32_021]: 400,
[-32_020]: 400
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,7 @@
import { describe, expect, test } from 'vitest';

import type { InboundHttpRequest, InboundLadderRejection } from '../../src/shared/inboundClassification';
import {
classifyInboundRequest,
httpStatusForErrorCode,
INBOUND_VALIDATION_LADDER,
LADDER_ERROR_HTTP_STATUS,
modernOnlyStrictRejection
} from '../../src/shared/inboundClassification';
import { classifyInboundRequest, INBOUND_VALIDATION_LADDER, modernOnlyStrictRejection } from '../../src/shared/inboundClassification';
import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../src/types/constants';

const MODERN_REVISION = '2026-07-28';
Expand Down Expand Up @@ -397,37 +391,6 @@ describe('the validation ladder as data', () => {
});
});

describe('HTTP status mapping for ladder-originated errors (origin-keyed)', () => {
test('the table maps exactly the ladder-originated codes', () => {
// The parse-error and invalid-request rows joined the table when the
// status matrix was completed alongside the cache fill / capability
// gate work; they were previously carried only by the classifier's own
// httpStatus on the rejection outcomes (same 400, now table-visible).
expect(LADDER_ERROR_HTTP_STATUS).toEqual({
[-32_700]: 400,
[-32_601]: 404,
[-32_600]: 400,
[-32_022]: 400,
[-32_021]: 400,
[-32_020]: 400
});
});

test('the table never maps invalid params: the classifier envelope short-circuit is the only -32602 -> 400 source', () => {
expect(Object.keys(LADDER_ERROR_HTTP_STATUS)).not.toContain(String(-32_602));
expect(httpStatusForErrorCode(-32_602, 'in-band')).toBe(200);
});

test('handler-originated errors stay in-band on HTTP 200, whatever their code', () => {
for (const code of [-32_603, -32_602, -32_601, -32_022, -32_002, -32_000, 1234]) {
expect(httpStatusForErrorCode(code, 'in-band')).toBe(200);
}
});

test('ladder-originated codes map to their HTTP statuses', () => {
expect(httpStatusForErrorCode(-32_601, 'ladder')).toBe(404);
expect(httpStatusForErrorCode(-32_022, 'ladder')).toBe(400);
expect(httpStatusForErrorCode(-32_021, 'ladder')).toBe(400);
expect(httpStatusForErrorCode(-32_020, 'ladder')).toBe(400);
});
});
// The error→HTTP-status policy (LADDER_ERROR_HTTP_STATUS / httpStatusForErrorCode)
// is pinned in errorHttpStatusMatrix.test.ts — the single test surface for that
// module. This file pins the classifier's cells only.
8 changes: 5 additions & 3 deletions packages/server/src/server/createMcpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -669,9 +669,11 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
// Pre-dispatch capability gate: a request to a method whose processing
// structurally requires a client capability the request's validated
// envelope did not declare is refused here, before any instance is
// constructed or dispatched. Answering at the entry pins the
// spec-mandated HTTP 400 for this error; a handler-time emission would
// surface in-band on HTTP 200.
// constructed or dispatched. Answering at the entry short-circuits
// before factory construction and pins the spec-mandated HTTP 400 for
// this error unconditionally; a handler-time -32021 (the
// input_required gate) also maps to 400 at the per-request transport,
// but only while no response has been committed.
if (route.messageKind === 'request') {
const required = requiredClientCapabilitiesForRequest(route.message.method);
if (required !== undefined) {
Expand Down
33 changes: 26 additions & 7 deletions packages/server/src/server/perRequestTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,14 @@
* stream, and request-header validation (which belongs to middleware). The
* exchange is single-use; serving another request requires a new transport
* (and, in the per-request serving model, a fresh server instance).
*
* Consumers wiring this transport into a custom entry (instead of
* `createMcpHandler`) inherit the spec's per-error HTTP mandate with it: a
* terminal `MissingRequiredClientCapabilityError` (-32021) MUST answer 400,
* which this transport applies whenever the response is still uncommitted.
*/
import type {
AuthInfo,
JSONRPCErrorResponse,
JSONRPCMessage,
JSONRPCNotification,
JSONRPCRequest,
Expand All @@ -45,6 +49,7 @@ import {
isJSONRPCRequest,
isJSONRPCResultResponse,
LADDER_ERROR_HTTP_STATUS,
ProtocolErrorCode,
SdkError,
SdkErrorCode
} from '@modelcontextprotocol/core-internal';
Expand Down Expand Up @@ -252,13 +257,27 @@ export class PerRequestHTTPServerTransport implements Transport {
// validation ladder, the era registry gate and handoff check, a
// missing handler — are answered with the mapped HTTP status from
// the ladder table. Handler-produced errors, whatever their code,
// stay in-band on HTTP 200. Ladder rejections keep that mapped
// status in every response mode (the SSE upgrade is deferred to
// the first actual send), so a forced-`sse` exchange still
// answers pre-dispatch rejections as plain HTTP errors.
// stay in-band on HTTP 200 — except
// MissingRequiredClientCapability (-32021), whose 400 the spec
// mandates per-error with no origin condition and whose only
// SDK-produced post-window source (the input_required capability
// gate) cannot fire any earlier — a handler-minted -32021 gets
// the same 400; a handler RELAYING a downstream peer's
// -32020/-32022 is not that peer's spec error and stays in-band.
// Must agree with httpStatusForErrorCode (core-internal), which
// is deliberately NOT called here: its `?? 400` ladder fallback
// would wrongly map window codes outside the table.
// The mapping applies only while no response has been committed:
// once the stream is open — the handler streamed first, or the
// exchange is forced-`sse` (which settles its 200 at dispatch
// end) — the status is on the wire and the error rides the
// stream. Pre-dispatch ladder rejections always precede the
// forced-`sse` upgrade, so they keep their mapped status in every
// response mode.
const errorCode = isJSONRPCErrorResponse(message) ? message.error.code : undefined;
const ladderStatus =
this._dispatchWindowOpen && isJSONRPCErrorResponse(message)
? LADDER_ERROR_HTTP_STATUS[(message as JSONRPCErrorResponse).error.code]
errorCode !== undefined && (this._dispatchWindowOpen || errorCode === ProtocolErrorCode.MissingRequiredClientCapability)
? LADDER_ERROR_HTTP_STATUS[errorCode]
: undefined;
if (ladderStatus !== undefined && this._sse === undefined) {
this.settleResponse(Response.json(message, { status: ladderStatus, headers: { 'Content-Type': 'application/json' } }));
Expand Down
38 changes: 38 additions & 0 deletions packages/server/test/server/perRequestTransport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,9 @@ describe('HTTP status mapping', () => {
});

it('keeps a handler-thrown unsupported-protocol-version error in-band on HTTP 200', async () => {
// A handler relaying a downstream peer's -32022 is not THIS server
// rejecting the caller's version; like the -32601 relay above it must
// not be re-mapped just because the ladder table maps that code.
const { server } = modernServer({
toolsCallHandler: async () => {
throw new ProtocolError(-32_022, 'Unsupported protocol version: 2099-01-01');
Expand All @@ -210,6 +213,41 @@ describe('HTTP status mapping', () => {
expect(errorOf(await response.json())?.code).toBe(-32_022);
});

it('maps a post-dispatch -32021 (MissingRequiredClientCapability) to HTTP 400: the spec mandates that status per-error', async () => {
const { server } = modernServer({
toolsCallHandler: async () => {
throw new ProtocolError(-32_021, 'Missing required client capabilities: sampling', {
requiredCapabilities: { sampling: {} }
});
}
});
const transport = await connectedTransport(server);
const response = await transport.handleMessage(toolsCall());
expect(response.status).toBe(400);
// The spec shape: `requiredCapabilities` is a ClientCapabilities
// OBJECT, never an array.
expect(errorOf(await response.json())).toMatchObject({ code: -32_021, data: { requiredCapabilities: { sampling: {} } } });
});

it('leaves a post-dispatch -32021 on the already-open HTTP 200 stream when the handler streamed first', async () => {
// Once the lazy SSE upgrade has happened, the 200 is committed — and
// the error must still REACH the client as the stream's terminal
// frame rather than being swallowed by the status-mapping arm.
const { server } = modernServer({
toolsCallHandler: async ctx => {
await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'capability-test', progress: 1 } });
throw new ProtocolError(-32_021, 'Missing required client capabilities: sampling');
}
});
const transport = await connectedTransport(server);
const response = await transport.handleMessage(toolsCall());
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('text/event-stream');
const frames = (await response.text()).split('\n\n').filter(frame => frame.includes('data: '));
const terminal = frames.at(-1)!;
expect(JSON.parse(terminal.split('data: ')[1]!)).toMatchObject({ id: 1, error: { code: -32_021 } });
});

it('keeps handler-produced invalid-params errors in-band on HTTP 200 (never status-mapped)', async () => {
const { server } = modernServer({
toolsCallHandler: async () => {
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading