From 7da5082d9fdd80714f7062174e288d4a1f460ee9 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 15 Jun 2026 20:31:26 +0000 Subject: [PATCH 1/8] feat(core): add inbound HTTP classification and validation-ladder modules Adds classifyInboundRequest, the body-primary era predicate for an HTTP entry serving both protocol eras on one endpoint: initialize and claim-less requests stay legacy, requests carrying the per-request _meta envelope claim classify modern, the MCP-Protocol-Version header is a cross-check only, notifications without a body claim follow the header, batch arrays are classified element-wise, and GET/DELETE are routed to legacy serving. A present claim with a malformed envelope is rejected with invalid params naming the offending key, never silently treated as legacy. Legacy routing outcomes carry no classification, so legacy and hand-wired traffic keeps today's dispatch behavior untouched. The validation ladder ships as data alongside the classifier: the rung order, an origin-keyed HTTP status table for ladder-originated error codes (invalid params is deliberately unmapped), and the modern-only strict mapping that answers envelope-less requests with the unsupported-protocol-version error and the endpoint's supported list. Header/body mismatch outcomes are emitted with a provisional code and marked unsettled because the exact assignments are still under discussion upstream; the cell-sheet tests assert them as parameterized rows rather than pinning a winner. Everything is exported on the core internal barrel only; nothing is added to the public API surface. --- packages/core/src/index.ts | 2 + packages/core/src/shared/envelope.ts | 99 +++ .../core/src/shared/inboundClassification.ts | 621 ++++++++++++++++++ .../test/shared/inboundClassification.test.ts | 375 +++++++++++ .../shared/inboundLadderCellSheet.test.ts | 391 +++++++++++ 5 files changed, 1488 insertions(+) create mode 100644 packages/core/src/shared/envelope.ts create mode 100644 packages/core/src/shared/inboundClassification.ts create mode 100644 packages/core/test/shared/inboundClassification.test.ts create mode 100644 packages/core/test/shared/inboundLadderCellSheet.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f5c11a5e0c..51d8204fe4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,8 @@ export * from './auth/errors.js'; export * from './errors/sdkErrors.js'; export * from './shared/auth.js'; export * from './shared/authUtils.js'; +export * from './shared/envelope.js'; +export * from './shared/inboundClassification.js'; export * from './shared/metadataUtils.js'; export * from './shared/protocol.js'; export * from './shared/protocolEras.js'; diff --git a/packages/core/src/shared/envelope.ts b/packages/core/src/shared/envelope.ts new file mode 100644 index 0000000000..3aba586452 --- /dev/null +++ b/packages/core/src/shared/envelope.ts @@ -0,0 +1,99 @@ +/** + * Per-request `_meta` envelope claim helpers (protocol revision 2026-07-28). + * + * Pure, value-returning helpers used by the inbound HTTP classifier + * (`classifyInboundRequest`): claim detection and envelope validation with + * self-identifying issues. The envelope schema itself stays the wire layer's + * single source of truth (`RequestMetaEnvelopeSchema`); this module only maps + * its outcomes into the shapes the validation ladder emits. + * + * Claim detection is deliberately narrow: a message claims the 2026-07-28 + * envelope mechanism if and only if the reserved protocol-version `_meta` key + * is present in `params._meta`. Other reserved keys (client info, client + * capabilities, log level), a bare `progressToken`, or unrelated keys under + * the `io.modelcontextprotocol/` prefix do NOT constitute a claim on their + * own — but once the claim key is present, a malformed envelope is a + * validation error, never a silent fall back to legacy handling. + */ +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '../types/constants.js'; +import { RequestMetaEnvelopeSchema } from '../wire/rev2026-07-28/schemas.js'; + +/** A single self-identifying problem found while validating a per-request `_meta` envelope. */ +export interface EnvelopeIssue { + /** + * The envelope key the problem is about: one of the reserved `_meta` keys, + * or a dotted path inside one (e.g. `io.modelcontextprotocol/clientInfo.name`). + */ + key: string; + /** A short description of what is wrong with that key (`missing`, or a validation message). */ + problem: string; +} + +/** The reserved `_meta` keys an envelope must carry (in reporting order). */ +const REQUIRED_ENVELOPE_KEYS: readonly string[] = [PROTOCOL_VERSION_META_KEY, CLIENT_INFO_META_KEY, CLIENT_CAPABILITIES_META_KEY]; + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** The `_meta` object of a message's params, when present. */ +export function requestMetaOf(params: unknown): Record | undefined { + if (!isPlainObject(params)) return undefined; + const meta = params['_meta']; + return isPlainObject(meta) ? meta : undefined; +} + +/** + * Whether a message's params carry the per-request envelope claim: the + * reserved protocol-version `_meta` key is present (regardless of whether the + * rest of the envelope is valid — validation is a separate, later step). + */ +export function hasEnvelopeClaim(params: unknown): boolean { + const meta = requestMetaOf(params); + return meta !== undefined && PROTOCOL_VERSION_META_KEY in meta; +} + +/** + * The protocol version named by a message's envelope claim, when the claim is + * present and carries a string value. A present claim with a non-string value + * still counts as a claim ({@linkcode hasEnvelopeClaim}); it surfaces as a + * validation issue instead of a version. + */ +export function envelopeClaimVersion(params: unknown): string | undefined { + const meta = requestMetaOf(params); + const value = meta?.[PROTOCOL_VERSION_META_KEY]; + return typeof value === 'string' ? value : undefined; +} + +/** + * Validates a request's `_meta` object as a 2026-07-28 per-request envelope + * and reports problems as self-identifying issues (which key, what problem). + * + * Returns an empty array when the envelope is valid. Missing required keys are + * reported first (as `problem: 'missing'`), then schema violations inside + * present keys, in a stable order. + */ +export function validateEnvelopeMeta(meta: Record): EnvelopeIssue[] { + const issues: EnvelopeIssue[] = []; + + for (const key of REQUIRED_ENVELOPE_KEYS) { + if (!(key in meta)) { + issues.push({ key, problem: 'missing' }); + } + } + + const parsed = RequestMetaEnvelopeSchema.safeParse(meta); + if (!parsed.success) { + for (const issue of parsed.error.issues) { + const path = issue.path.map(String); + const key = path.length > 0 ? path.join('.') : '_meta'; + // Missing required keys were already reported above in canonical order. + if (path.length === 1 && issues.some(existing => existing.key === key && existing.problem === 'missing')) { + continue; + } + issues.push({ key, problem: issue.message }); + } + } + + return issues; +} diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts new file mode 100644 index 0000000000..28433bcd77 --- /dev/null +++ b/packages/core/src/shared/inboundClassification.ts @@ -0,0 +1,621 @@ +/** + * Inbound HTTP request classification and the inbound validation ladder + * (protocol revision 2026-07-28). + * + * `classifyInboundRequest` is the body-primary era predicate for an HTTP + * entry that serves both protocol eras on one endpoint. It is evaluated + * exactly once, at the entry boundary, on the already-parsed request body: + * + * - `initialize` is a legacy-era request by definition (the modern era has no + * `initialize` handshake). + * - A request whose `params._meta` carries the reserved protocol-version key + * claims the per-request envelope mechanism and classifies into the era the + * named revision belongs to (a malformed envelope behind a present claim is + * a validation error, never a silent fall back to legacy handling). + * - A request without a claim is legacy-era traffic. + * - The `MCP-Protocol-Version` header is a cross-check only: it never + * upgrades or downgrades a body-derived classification, and a disagreement + * between header and body is an explicit ladder outcome. + * - Notifications carry no envelope claim of their own under the current + * spec, so for notification POSTs without a body claim the modern header is + * determinative; the `Mcp-Method` header is validated against the body when + * the message classifies modern and is never enforced on legacy traffic. + * - `GET`/`DELETE` (and any other non-`POST` method) are body-less 2025-era + * session operations: the modern era is `POST`-only, so they are routed to + * legacy serving when it is configured and rejected otherwise. + * - Array (batch) bodies are classified element-wise: an array containing a + * modern-claiming or invalid element is rejected, an all-legacy array is + * legacy traffic unchanged, and a single-element array is still an array. + * + * The classifier returns plain values (it never throws and never touches a + * transport): a routing outcome (`legacy`/`modern`) or a ladder rejection + * carrying the JSON-RPC error to emit and the HTTP status to emit it with. + * Legacy routing outcomes deliberately carry NO `MessageClassification` — + * legacy and hand-wired traffic is never classified, which keeps its + * dispatch behavior byte-identical to today's. + * + * Some ladder cells do not have a settled error code upstream yet (the + * header/body mismatch family: the candidate codes are `-32001`, `-32602` + * and `-32004`; see the note in `test/conformance/expected-failures.yaml`). + * Those outcomes are emitted with a single provisional code and are marked + * `settled: false` so tests and consumers can treat them as parameterized + * rather than pinned. + */ +import { LATEST_PROTOCOL_VERSION } from '../types/constants.js'; +import { ProtocolErrorCode } from '../types/enums.js'; +import { ProtocolError, UnsupportedProtocolVersionError } from '../types/errors.js'; +import { isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse } from '../types/guards.js'; +import type { MessageClassification } from '../types/types.js'; +import { envelopeClaimVersion, hasEnvelopeClaim, requestMetaOf, validateEnvelopeMeta } from './envelope.js'; +import { isModernProtocolVersion } from './protocolEras.js'; + +/* ------------------------------------------------------------------------ * + * Classifier input + * ------------------------------------------------------------------------ */ + +/** + * The transport-neutral description of an inbound HTTP request the classifier + * evaluates. The caller (the HTTP entry) reads the body exactly once and + * extracts the two protocol headers; the classifier never touches a request + * object itself. + */ +export interface InboundHttpRequest { + /** The HTTP request method, e.g. `POST`, `GET`, `DELETE`. */ + httpMethod: string; + /** The value of the `MCP-Protocol-Version` header, when present. */ + protocolVersionHeader?: string; + /** The value of the `Mcp-Method` header, when present. */ + mcpMethodHeader?: string; + /** The parsed JSON request body (`undefined` for body-less methods). */ + body?: unknown; +} + +/* ------------------------------------------------------------------------ * + * Classifier outcomes + * ------------------------------------------------------------------------ */ + +/** Why an inbound request was routed to legacy-era serving. */ +export type InboundLegacyRouteReason = + /** Non-`POST` HTTP method: a body-less 2025-era session operation. */ + | 'http-method' + /** An `initialize` request — the legacy handshake by definition. */ + | 'initialize' + /** A request without a per-request envelope claim. */ + | 'no-claim' + /** A notification without a body claim or a modern protocol-version header. */ + | 'notification' + /** An all-legacy JSON-RPC batch array. */ + | 'batch' + /** A JSON-RPC response posted to the endpoint (2025-era session traffic). */ + | 'response'; + +/** + * The request is legacy-era traffic. It carries no classification on purpose: + * legacy serving receives it exactly as a hand-wired 2025 transport would. + */ +export interface InboundLegacyRoute { + kind: 'legacy'; + reason: InboundLegacyRouteReason; + /** + * The protocol version the request named, when it named one (an + * `initialize` body's `protocolVersion`, or the `MCP-Protocol-Version` + * header). Used to echo `requested` when legacy serving is not configured. + */ + requestedVersion?: string; +} + +/** The request claims the per-request envelope mechanism and is served on the modern path. */ +export interface InboundModernRoute { + kind: 'modern'; + /** Whether the classified message is a request or a notification. */ + messageKind: 'request' | 'notification'; + /** + * The classification handed to the per-request transport and validated by + * the protocol layer against the serving instance's negotiated era. + */ + classification: MessageClassification; +} + +/** The named steps of the inbound validation ladder, in evaluation order. */ +export type InboundValidationRung = + | 'http-method' + | 'jsonrpc-shape' + | 'era-classification' + | 'envelope' + | 'method-registry' + | 'request-params' + | 'client-capabilities'; + +/** A ladder rejection: the JSON-RPC error to emit and the HTTP status to emit it with. */ +export interface InboundLadderRejection { + kind: 'reject'; + /** The ladder rung that produced the rejection. */ + rung: InboundValidationRung; + /** The cell this rejection corresponds to on the ladder cell sheet (stable identifier for tests). */ + cell: string; + /** The HTTP status the rejection is emitted with. */ + httpStatus: number; + /** The JSON-RPC error code. */ + code: number; + /** The JSON-RPC error message. */ + message: string; + /** Structured error data (recognizers parse this; they never rely on class identity). */ + data?: unknown; + /** + * `false` when the exact error code for this cell is not settled upstream + * yet and the emitted code is provisional. + */ + settled: boolean; +} + +/** The outcome of classifying one inbound HTTP request. */ +export type InboundClassificationOutcome = InboundLegacyRoute | InboundModernRoute | InboundLadderRejection; + +/* ------------------------------------------------------------------------ * + * The validation ladder as data + * ------------------------------------------------------------------------ */ + +/** One rung of the inbound validation ladder. */ +export interface InboundValidationRungDescriptor { + rung: InboundValidationRung; + /** Evaluation order: lower runs first; an earlier rung's outcome wins over a later rung's. */ + order: number; + /** Where the rung is evaluated: at the HTTP entry edge or at protocol dispatch. */ + evaluatedAt: 'edge' | 'dispatch'; + /** The JSON-RPC error codes this rung can produce (empty when the rung only routes). */ + codes: readonly number[]; + /** Conformance scenarios that exercise this rung (where one exists). */ + conformance: readonly string[]; + /** Why the rung sits where it does. */ + rationale: string; +} + +/** + * The inbound validation ladder, expressed as data rather than control flow. + * + * The edge rungs are evaluated by {@linkcode classifyInboundRequest}; the + * dispatch rungs are evaluated by the protocol layer once the classified + * message is injected into a per-request server instance (the era registry + * gate, the envelope requiredness check, per-method params validation, and + * the client-capability check). The order is the precedence: a request that + * fails several rungs is answered by the earliest one. + */ +export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor[] = [ + { + rung: 'http-method', + order: 1, + evaluatedAt: 'edge', + codes: [-32_000], + conformance: [], + rationale: + 'The modern era is POST-only; GET/DELETE are body-less 2025-era session operations and are method-routed to legacy ' + + 'serving (405 when legacy serving is not configured), before any body is read.' + }, + { + rung: 'jsonrpc-shape', + order: 2, + evaluatedAt: 'edge', + codes: [ProtocolErrorCode.InvalidRequest], + conformance: ['server-stateless'], + rationale: + 'The body must be a JSON-RPC request or notification: posted responses and batch arrays containing a modern or ' + + 'invalid element are rejected before classification (element-wise batch rule); all-legacy arrays stay legacy traffic.' + }, + { + rung: 'era-classification', + order: 3, + evaluatedAt: 'edge', + codes: [ProtocolErrorCode.UnsupportedProtocolVersion], + conformance: ['server-stateless', 'http-header-validation', 'http-custom-header-server-validation'], + rationale: + 'Body-primary era classification with the protocol-version header as a cross-check; a header/body disagreement is a ' + + 'distinct outcome whose exact error code is still under discussion upstream (provisional, see expected-failures.yaml).' + }, + { + rung: 'envelope', + order: 4, + evaluatedAt: 'edge', + codes: [ProtocolErrorCode.InvalidParams], + conformance: ['server-stateless'], + rationale: + 'A present envelope claim with a malformed envelope is an invalid-params rejection naming the offending key — never a ' + + 'silent fall back to legacy handling. This is the only place an invalid-params rejection maps to HTTP 400.' + }, + { + rung: 'method-registry', + order: 5, + evaluatedAt: 'dispatch', + codes: [ProtocolErrorCode.MethodNotFound], + conformance: ['server-stateless'], + rationale: + 'Method existence outranks parameter validity: a method absent from the negotiated revision’s registry (or with no ' + + 'handler installed) answers method-not-found before params or capabilities are looked at.' + }, + { + rung: 'request-params', + order: 6, + evaluatedAt: 'dispatch', + codes: [ProtocolErrorCode.InvalidParams], + conformance: [], + rationale: 'Per-method params validation; emitted in-band by the dispatch layer (HTTP 200), never via the ladder status table.' + }, + { + rung: 'client-capabilities', + order: 7, + evaluatedAt: 'dispatch', + codes: [ProtocolErrorCode.MissingRequiredClientCapability], + conformance: ['server-stateless'], + rationale: + 'Capability assertion runs after envelope validation and method resolution, immediately before the handler; the ' + + 'emission itself ships with the capability-policy work and is recorded here for ordering only.' + } +]; + +/* ------------------------------------------------------------------------ * + * HTTP status mapping for ladder-originated errors + * ------------------------------------------------------------------------ */ + +/** + * HTTP status for ladder-originated JSON-RPC error codes. + * + * Keyed on origin, not on the bare code: this table only applies to errors + * 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. + * + * `-32602` (invalid params) deliberately has NO entry: the only invalid-params + * rejection that maps to HTTP 400 is the classifier's own envelope rung + * short-circuit, which carries its HTTP status directly. A dispatch- or + * handler-produced invalid-params error is always in-band. + */ +export const LADDER_ERROR_HTTP_STATUS: Readonly> = { + [ProtocolErrorCode.MethodNotFound]: 404, + [ProtocolErrorCode.UnsupportedProtocolVersion]: 400, + [ProtocolErrorCode.MissingRequiredClientCapability]: 400, + [-32_001]: 400 +}; + +/** + * 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}. + */ +export function httpStatusForErrorCode(code: number, origin: 'ladder' | 'in-band'): number { + if (origin === 'in-band') return 200; + return LADDER_ERROR_HTTP_STATUS[code] ?? 400; +} + +/* ------------------------------------------------------------------------ * + * Provisional cells + * ------------------------------------------------------------------------ */ + +/** + * The error code emitted for header/body cross-check mismatches (the + * protocol-version header disagreeing with the body classification, and the + * `Mcp-Method` header disagreeing with the body method). + * + * The exact code for these cells is still under discussion upstream — the + * candidates are `-32001`, `-32602` and `-32004` (see the note in + * `test/conformance/expected-failures.yaml`). Until a published conformance + * release settles them, the ladder emits the protocol-layer era-mismatch code + * and marks the outcome `settled: false`. + */ +export const PROVISIONAL_CROSS_CHECK_MISMATCH_CODE: number = ProtocolErrorCode.UnsupportedProtocolVersion; + +/* ------------------------------------------------------------------------ * + * The classifier + * ------------------------------------------------------------------------ */ + +function rejection( + rung: InboundValidationRung, + cell: string, + httpStatus: number, + error: ProtocolError, + settled: boolean +): InboundLadderRejection { + return { + kind: 'reject', + rung, + cell, + httpStatus, + code: error.code, + message: error.message, + ...(error.data !== undefined && { data: error.data }), + settled + }; +} + +function crossCheckMismatch(cell: string, header: string, body: string): InboundLadderRejection { + return rejection( + 'era-classification', + cell, + 400, + new ProtocolError(PROVISIONAL_CROSS_CHECK_MISMATCH_CODE, `Bad Request: the request headers and body disagree: ${body}`, { + mismatch: { header, body } + }), + false + ); +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function classificationForClaim(claimedVersion: string | undefined): MessageClassification { + if (claimedVersion === undefined) { + return { era: 'modern' }; + } + return { era: isModernProtocolVersion(claimedVersion) ? 'modern' : 'legacy', revision: claimedVersion }; +} + +function classifyBatch(body: readonly unknown[]): InboundClassificationOutcome { + if (body.length === 0) { + return rejection( + 'jsonrpc-shape', + 'empty-batch', + 400, + new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Bad Request: empty JSON-RPC batch'), + true + ); + } + for (const element of body) { + const params = isPlainObject(element) ? element['params'] : undefined; + if (hasEnvelopeClaim(params)) { + // Element-wise rule: a single modern element makes the whole array + // unservable — modern requests are single-message POSTs, and the + // legacy path must never serve an envelope-claiming element. + return rejection( + 'jsonrpc-shape', + 'batch-with-modern-element', + 400, + new ProtocolError( + ProtocolErrorCode.InvalidRequest, + 'Bad Request: JSON-RPC batches may not contain requests for protocol revision 2026-07-28 or later' + ), + true + ); + } + const valid = + isJSONRPCRequest(element) || + isJSONRPCNotification(element) || + isJSONRPCResultResponse(element) || + isJSONRPCErrorResponse(element); + if (!valid) { + return rejection( + 'jsonrpc-shape', + 'batch-with-invalid-element', + 400, + new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Bad Request: JSON-RPC batch contains an invalid message'), + true + ); + } + } + // All elements are legacy-era messages: legacy serving takes the array unchanged. + return { kind: 'legacy', reason: 'batch' }; +} + +function classifyRequestBody(request: InboundHttpRequest, body: Record): InboundClassificationOutcome { + const params = body['params']; + const method = body['method'] as string; + const headerVersion = request.protocolVersionHeader; + const headerNamesModern = headerVersion !== undefined && isModernProtocolVersion(headerVersion); + + // `initialize` is the legacy handshake by definition — before claim detection. + if (method === 'initialize') { + if (headerNamesModern) { + return crossCheckMismatch( + 'initialize-with-modern-header', + headerVersion, + 'an initialize request (legacy handshake) was sent with a modern MCP-Protocol-Version header' + ); + } + const requestedVersion = + isPlainObject(params) && typeof params['protocolVersion'] === 'string' ? params['protocolVersion'] : undefined; + return { kind: 'legacy', reason: 'initialize', ...(requestedVersion !== undefined && { requestedVersion }) }; + } + + if (hasEnvelopeClaim(params)) { + // A present claim is validated, never silently ignored: a malformed + // envelope behind the claim is an invalid-params rejection naming the + // offending key, not a fall back to legacy handling. + const meta = requestMetaOf(params); + const issues = meta === undefined ? [] : validateEnvelopeMeta(meta); + const firstIssue = issues[0]; + if (firstIssue !== undefined) { + return rejection( + 'envelope', + 'envelope-invalid', + 400, + new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Invalid _meta envelope for protocol revision 2026-07-28: ${firstIssue.key}: ${firstIssue.problem}`, + { envelope: firstIssue } + ), + true + ); + } + + const claimedVersion = envelopeClaimVersion(params); + if (headerVersion !== undefined && claimedVersion !== undefined && headerVersion !== claimedVersion) { + return crossCheckMismatch( + 'header-body-version-mismatch', + headerVersion, + `the body envelope names protocol version ${claimedVersion} but the MCP-Protocol-Version header names ${headerVersion}` + ); + } + if (request.mcpMethodHeader !== undefined && request.mcpMethodHeader !== method) { + return crossCheckMismatch( + 'method-header-mismatch', + request.mcpMethodHeader, + `the body names method ${method} but the Mcp-Method header names ${request.mcpMethodHeader}` + ); + } + return { kind: 'modern', messageKind: 'request', classification: classificationForClaim(claimedVersion) }; + } + + // No claim: legacy-era traffic. The header is a cross-check only — a + // modern header on a claim-less body is a disagreement, not an upgrade. + if (headerNamesModern) { + return crossCheckMismatch( + 'modern-header-without-claim', + headerVersion, + 'the MCP-Protocol-Version header names a modern protocol revision but the request body carries no _meta envelope claim' + ); + } + return { kind: 'legacy', reason: 'no-claim', ...(headerVersion !== undefined && { requestedVersion: headerVersion }) }; +} + +function classifyNotificationBody(request: InboundHttpRequest, body: Record): InboundClassificationOutcome { + const params = body['params']; + const method = body['method'] as string; + const headerVersion = request.protocolVersionHeader; + const headerNamesModern = headerVersion !== undefined && isModernProtocolVersion(headerVersion); + + if (hasEnvelopeClaim(params)) { + // Body-primary even for notifications: a body claim wins over the + // header, and a disagreement between them is rejected rather than + // letting either signal silently pick the serving path. + const claimedVersion = envelopeClaimVersion(params); + if (headerVersion !== undefined && claimedVersion !== undefined && headerVersion !== claimedVersion) { + return crossCheckMismatch( + 'notification-header-body-version-mismatch', + headerVersion, + `the notification envelope names protocol version ${claimedVersion} but the MCP-Protocol-Version header names ${headerVersion}` + ); + } + const classification = classificationForClaim(claimedVersion); + if (classification.era === 'modern' && request.mcpMethodHeader !== undefined && request.mcpMethodHeader !== method) { + return crossCheckMismatch( + 'notification-method-header-mismatch', + request.mcpMethodHeader, + `the notification body names method ${method} but the Mcp-Method header names ${request.mcpMethodHeader}` + ); + } + return { kind: 'modern', messageKind: 'notification', classification }; + } + + // Notifications carry no body claim under the current spec, so the + // protocol-version header is determinative for them: a modern header + // routes the notification to modern serving; a missing or legacy header + // keeps it legacy traffic. The Mcp-Method header is validated only when + // the notification classifies modern — it is never enforced on legacy + // notifications. + if (headerNamesModern) { + if (request.mcpMethodHeader !== undefined && request.mcpMethodHeader !== method) { + return crossCheckMismatch( + 'notification-method-header-mismatch', + request.mcpMethodHeader, + `the notification body names method ${method} but the Mcp-Method header names ${request.mcpMethodHeader}` + ); + } + return { + kind: 'modern', + messageKind: 'notification', + classification: { era: 'modern', revision: headerVersion } + }; + } + return { kind: 'legacy', reason: 'notification', ...(headerVersion !== undefined && { requestedVersion: headerVersion }) }; +} + +/** + * Classifies one inbound HTTP request for dual-era serving. + * + * The body-primary predicate, evaluated once at the entry boundary: see the + * module documentation for the rules. Returns a routing outcome (`legacy` or + * `modern`) or a ladder rejection; it never throws. + */ +export function classifyInboundRequest(request: InboundHttpRequest): InboundClassificationOutcome { + if (request.httpMethod.toUpperCase() !== 'POST') { + // Body-less 2025-era session operations (and any other non-POST + // method): the modern era is POST-only. + return { kind: 'legacy', reason: 'http-method' }; + } + + const body = request.body; + if (Array.isArray(body)) { + return classifyBatch(body); + } + if (isJSONRPCResultResponse(body) || isJSONRPCErrorResponse(body)) { + // Posted responses are 2025-era session traffic (replies to + // server-initiated requests over a session); the modern era has no + // such channel. + return { kind: 'legacy', reason: 'response' }; + } + if (isPlainObject(body) && isJSONRPCRequest(body)) { + return classifyRequestBody(request, body); + } + if (isPlainObject(body) && isJSONRPCNotification(body)) { + return classifyNotificationBody(request, body); + } + return rejection( + 'jsonrpc-shape', + 'invalid-json-rpc-body', + 400, + new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Bad Request: the request body is not a valid JSON-RPC message'), + true + ); +} + +/* ------------------------------------------------------------------------ * + * Modern-only (strict) mapping of legacy routes + * ------------------------------------------------------------------------ */ + +/** + * The rejection a modern-only endpoint (no legacy serving configured) + * answers a legacy-classified request with. + * + * - Envelope-less requests (including `initialize`) are answered with the + * unsupported-protocol-version error carrying the endpoint's supported + * versions and echoing the version the request named, so a legacy client + * can discover what the endpoint serves from the error alone. (This cell + * shares its numeric code with the still-disputed mismatch cells above, + * but its own outcome is settled.) + * - Posted responses and batch arrays are invalid requests on the modern era. + * - Non-`POST` methods are not allowed. + * - Legacy-classified notifications return `undefined`: the caller answers + * 202 with no body and does not dispatch the notification (accept-and-drop). + */ +export function modernOnlyStrictRejection( + route: InboundLegacyRoute, + supportedVersions: readonly string[] +): InboundLadderRejection | undefined { + switch (route.reason) { + case 'http-method': { + return rejection('http-method', 'modern-only-method-not-allowed', 405, new ProtocolError(-32_000, 'Method not allowed.'), true); + } + case 'batch': { + return rejection( + 'jsonrpc-shape', + 'modern-only-batch-not-supported', + 400, + new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Bad Request: JSON-RPC batches are not supported by this endpoint'), + true + ); + } + case 'response': { + return rejection( + 'jsonrpc-shape', + 'modern-only-response-post', + 400, + new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Bad Request: JSON-RPC responses cannot be posted to this endpoint'), + true + ); + } + case 'notification': { + return undefined; + } + case 'initialize': + case 'no-claim': { + const requested = route.requestedVersion ?? LATEST_PROTOCOL_VERSION; + return rejection( + 'era-classification', + 'modern-only-missing-envelope', + 400, + new UnsupportedProtocolVersionError({ supported: [...supportedVersions], requested }), + true + ); + } + } +} diff --git a/packages/core/test/shared/inboundClassification.test.ts b/packages/core/test/shared/inboundClassification.test.ts new file mode 100644 index 0000000000..523a7dd698 --- /dev/null +++ b/packages/core/test/shared/inboundClassification.test.ts @@ -0,0 +1,375 @@ +/** + * Unit tests for the inbound HTTP classifier (`classifyInboundRequest`) and + * the envelope claim helpers: the body-primary era predicate, claim + * detection, envelope validation with self-identifying issues, the header + * cross-checks, notification routing, element-wise batch classification, and + * the modern-only (strict) rejection mapping. + * + * Cells whose exact error code is still under discussion upstream (the + * header/body mismatch family) are asserted as parameterized: the outcome is + * pinned (a rejection, marked unsettled), the code is asserted to be the + * provisional constant and a member of the candidate set — never a hard-coded + * literal of its own. + */ +import { describe, expect, test } from 'vitest'; + +import { hasEnvelopeClaim, validateEnvelopeMeta } from '../../src/shared/envelope.js'; +import type { InboundHttpRequest, InboundLegacyRoute } from '../../src/shared/inboundClassification.js'; +import { + classifyInboundRequest, + modernOnlyStrictRejection, + PROVISIONAL_CROSS_CHECK_MISMATCH_CODE +} from '../../src/shared/inboundClassification.js'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY +} from '../../src/types/constants.js'; + +const MODERN_REVISION = '2026-07-28'; +const MISMATCH_CODE_CANDIDATES = [-32_001, -32_602, -32_004]; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'classifier-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +const modernToolsCall = (meta: Record = ENVELOPE) => ({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'echo', arguments: {}, _meta: meta } +}); + +const legacyToolsList = () => ({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); + +const initializeRequest = (protocolVersion = '2025-06-18') => ({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion, capabilities: {}, clientInfo: { name: 'legacy-client', version: '1.0.0' } } +}); + +const notification = (method = 'notifications/initialized', meta?: Record) => ({ + jsonrpc: '2.0', + method, + ...(meta === undefined ? {} : { params: { _meta: meta } }) +}); + +const post = (body: unknown, headers: { protocolVersion?: string; mcpMethod?: string } = {}): InboundHttpRequest => ({ + httpMethod: 'POST', + body, + ...(headers.protocolVersion !== undefined && { protocolVersionHeader: headers.protocolVersion }), + ...(headers.mcpMethod !== undefined && { mcpMethodHeader: headers.mcpMethod }) +}); + +const expectMismatch = (outcome: ReturnType, cell: string) => { + expect(outcome.kind).toBe('reject'); + if (outcome.kind !== 'reject') return; + expect(outcome.cell).toBe(cell); + expect(outcome.rung).toBe('era-classification'); + expect(outcome.httpStatus).toBe(400); + // Parameterized: the exact code for the mismatch family is not settled + // upstream. The classifier emits the provisional constant; assert set + // membership rather than a literal of our own. + expect(outcome.settled).toBe(false); + expect(outcome.code).toBe(PROVISIONAL_CROSS_CHECK_MISMATCH_CODE); + expect(MISMATCH_CODE_CANDIDATES).toContain(outcome.code); +}; + +describe('envelope claim detection (claim = the reserved protocol-version key)', () => { + test('a progress-token-only _meta is not a claim', () => { + expect(hasEnvelopeClaim({ _meta: { progressToken: 'token-1' } })).toBe(false); + }); + + test('client info / client capabilities alone are not a claim', () => { + expect( + hasEnvelopeClaim({ + _meta: { [CLIENT_INFO_META_KEY]: { name: 'c', version: '1' }, [CLIENT_CAPABILITIES_META_KEY]: {} } + }) + ).toBe(false); + }); + + test('stray reserved-prefix keys are ignored by claim detection', () => { + expect(hasEnvelopeClaim({ _meta: { 'io.modelcontextprotocol/somethingElse': true } })).toBe(false); + }); + + test('the protocol-version key alone is a claim, even with a non-string value', () => { + expect(hasEnvelopeClaim({ _meta: { [PROTOCOL_VERSION_META_KEY]: 42 } })).toBe(true); + }); +}); + +describe('envelope validation issues are self-identifying (key + problem)', () => { + test('missing required keys are reported in canonical order', () => { + const issues = validateEnvelopeMeta({ [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION }); + expect(issues.map(issue => issue.key)).toEqual([CLIENT_INFO_META_KEY, CLIENT_CAPABILITIES_META_KEY]); + expect(issues.every(issue => issue.problem === 'missing')).toBe(true); + }); + + test('a malformed value inside a present key names the key', () => { + const issues = validateEnvelopeMeta({ ...ENVELOPE, [CLIENT_INFO_META_KEY]: { version: '1.0.0' } }); + expect(issues.length).toBeGreaterThan(0); + expect(issues[0]?.key).toContain(CLIENT_INFO_META_KEY); + expect(issues[0]?.problem).not.toBe('missing'); + }); + + test('a complete, well-formed envelope produces no issues', () => { + expect(validateEnvelopeMeta(ENVELOPE)).toEqual([]); + }); +}); + +describe('body-primary era predicate', () => { + test('an envelope-claiming request with a matching header classifies modern', () => { + const outcome = classifyInboundRequest(post(modernToolsCall(), { protocolVersion: MODERN_REVISION })); + expect(outcome).toMatchObject({ + kind: 'modern', + messageKind: 'request', + classification: { era: 'modern', revision: MODERN_REVISION } + }); + }); + + test('a header-stripped request still classifies modern from the body claim alone', () => { + // Robustness to proxies/CDNs stripping the MCP-Protocol-Version header: + // the body claim is primary. + const outcome = classifyInboundRequest(post(modernToolsCall())); + expect(outcome).toMatchObject({ + kind: 'modern', + messageKind: 'request', + classification: { era: 'modern', revision: MODERN_REVISION } + }); + }); + + test('a claim-less request is legacy traffic and carries no classification', () => { + const outcome = classifyInboundRequest(post(legacyToolsList(), { protocolVersion: '2025-06-18' })); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'no-claim', requestedVersion: '2025-06-18' }); + expect('classification' in outcome).toBe(false); + }); + + test('initialize is the legacy handshake by definition', () => { + const outcome = classifyInboundRequest(post(initializeRequest('2025-03-26'))); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'initialize', requestedVersion: '2025-03-26' }); + }); + + test('GET and DELETE are method-routed legacy session operations', () => { + expect(classifyInboundRequest({ httpMethod: 'GET' })).toMatchObject({ kind: 'legacy', reason: 'http-method' }); + expect(classifyInboundRequest({ httpMethod: 'DELETE' })).toMatchObject({ kind: 'legacy', reason: 'http-method' }); + }); + + test('a claim naming a legacy revision keeps the named revision on the classification', () => { + // The envelope mechanism naming a pre-2026 revision is carried as-is; + // the serving instance answers it through the protocol-version + // mismatch handoff rather than being silently re-routed. + const meta = { ...ENVELOPE, [PROTOCOL_VERSION_META_KEY]: '2025-06-18' }; + const outcome = classifyInboundRequest(post(modernToolsCall(meta))); + expect(outcome).toMatchObject({ kind: 'modern', classification: { era: 'legacy', revision: '2025-06-18' } }); + }); + + test('a claim with a malformed envelope is rejected, never silently treated as legacy', () => { + const meta = { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION }; + const outcome = classifyInboundRequest(post(modernToolsCall(meta))); + expect(outcome).toMatchObject({ + kind: 'reject', + rung: 'envelope', + cell: 'envelope-invalid', + httpStatus: 400, + code: -32_602, + settled: true, + data: { envelope: { key: CLIENT_INFO_META_KEY, problem: 'missing' } } + }); + }); + + test('a claim with malformed client capabilities names the offending key', () => { + const meta = { ...ENVELOPE, [CLIENT_CAPABILITIES_META_KEY]: { sampling: 'yes' } }; + const outcome = classifyInboundRequest(post(modernToolsCall(meta))); + expect(outcome.kind).toBe('reject'); + if (outcome.kind !== 'reject') return; + expect(outcome.code).toBe(-32_602); + const data = outcome.data as { envelope: { key: string } }; + expect(data.envelope.key).toContain(CLIENT_CAPABILITIES_META_KEY); + }); +}); + +describe('header cross-checks (parameterized mismatch family)', () => { + test('a body claim disagreeing with the protocol-version header is a mismatch outcome', () => { + const outcome = classifyInboundRequest(post(modernToolsCall(), { protocolVersion: '2025-06-18' })); + expectMismatch(outcome, 'header-body-version-mismatch'); + }); + + test('a modern header on a claim-less body is a mismatch outcome, not an upgrade', () => { + const outcome = classifyInboundRequest(post(legacyToolsList(), { protocolVersion: MODERN_REVISION })); + expectMismatch(outcome, 'modern-header-without-claim'); + }); + + test('initialize with a modern protocol-version header is a mismatch outcome', () => { + const outcome = classifyInboundRequest(post(initializeRequest(), { protocolVersion: MODERN_REVISION })); + expectMismatch(outcome, 'initialize-with-modern-header'); + }); + + test('an Mcp-Method header disagreeing with the body method is a mismatch outcome on modern requests', () => { + const outcome = classifyInboundRequest(post(modernToolsCall(), { protocolVersion: MODERN_REVISION, mcpMethod: 'tools/list' })); + expectMismatch(outcome, 'method-header-mismatch'); + }); + + test('a matching Mcp-Method header passes', () => { + const outcome = classifyInboundRequest(post(modernToolsCall(), { protocolVersion: MODERN_REVISION, mcpMethod: 'tools/call' })); + expect(outcome.kind).toBe('modern'); + }); + + test('the Mcp-Method header is never enforced on legacy requests', () => { + const outcome = classifyInboundRequest(post(legacyToolsList(), { protocolVersion: '2025-06-18', mcpMethod: 'tools/call' })); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'no-claim' }); + }); +}); + +describe('notification routing (header determinative when the body carries no claim)', () => { + test('a modern protocol-version header routes a claim-less notification to modern serving', () => { + const outcome = classifyInboundRequest(post(notification(), { protocolVersion: MODERN_REVISION })); + expect(outcome).toMatchObject({ + kind: 'modern', + messageKind: 'notification', + classification: { era: 'modern', revision: MODERN_REVISION } + }); + }); + + test('a header-stripped notification stays legacy traffic', () => { + const outcome = classifyInboundRequest(post(notification())); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'notification' }); + }); + + test('a legacy protocol-version header keeps the notification legacy', () => { + const outcome = classifyInboundRequest(post(notification(), { protocolVersion: '2025-06-18' })); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'notification', requestedVersion: '2025-06-18' }); + }); + + test('the Mcp-Method header is validated on modern notifications', () => { + const outcome = classifyInboundRequest( + post(notification('notifications/progress'), { protocolVersion: MODERN_REVISION, mcpMethod: 'notifications/cancelled' }) + ); + expectMismatch(outcome, 'notification-method-header-mismatch'); + }); + + test('the Mcp-Method header is never enforced on legacy notifications', () => { + const outcome = classifyInboundRequest( + post(notification('notifications/progress'), { protocolVersion: '2025-06-18', mcpMethod: 'notifications/cancelled' }) + ); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'notification' }); + }); + + test('a notification body claim wins over the header and a disagreement is rejected', () => { + const meta = { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION }; + const claimed = classifyInboundRequest(post(notification('notifications/progress', meta))); + expect(claimed).toMatchObject({ kind: 'modern', classification: { revision: MODERN_REVISION } }); + + const conflicting = classifyInboundRequest(post(notification('notifications/progress', meta), { protocolVersion: '2025-06-18' })); + expectMismatch(conflicting, 'notification-header-body-version-mismatch'); + }); +}); + +describe('element-wise batch classification', () => { + test('an all-legacy array stays legacy traffic unchanged', () => { + const outcome = classifyInboundRequest(post([legacyToolsList(), notification()])); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'batch' }); + }); + + test('a single-element array is still an array', () => { + const outcome = classifyInboundRequest(post([legacyToolsList()])); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'batch' }); + }); + + test('an array containing a response element stays legacy traffic', () => { + const outcome = classifyInboundRequest(post([{ jsonrpc: '2.0', id: 9, result: {} }, legacyToolsList()])); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'batch' }); + }); + + test('an array containing a modern-claiming element is rejected', () => { + const outcome = classifyInboundRequest(post([legacyToolsList(), modernToolsCall()])); + expect(outcome).toMatchObject({ kind: 'reject', cell: 'batch-with-modern-element', code: -32_600, httpStatus: 400, settled: true }); + }); + + test('an array containing an invalid element is rejected', () => { + const outcome = classifyInboundRequest(post([legacyToolsList(), { not: 'json-rpc' }])); + expect(outcome).toMatchObject({ kind: 'reject', cell: 'batch-with-invalid-element', code: -32_600, httpStatus: 400 }); + }); + + test('an empty array is rejected', () => { + const outcome = classifyInboundRequest(post([])); + expect(outcome).toMatchObject({ kind: 'reject', cell: 'empty-batch', code: -32_600 }); + }); +}); + +describe('responses and malformed bodies', () => { + test('a posted result response is legacy session traffic', () => { + const outcome = classifyInboundRequest(post({ jsonrpc: '2.0', id: 3, result: {} })); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'response' }); + }); + + test('a posted error response is legacy session traffic', () => { + const outcome = classifyInboundRequest(post({ jsonrpc: '2.0', id: 3, error: { code: -32_000, message: 'oops' } })); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'response' }); + }); + + test('a body that is not a JSON-RPC message is rejected', () => { + const outcome = classifyInboundRequest(post({ hello: 'world' })); + expect(outcome).toMatchObject({ kind: 'reject', cell: 'invalid-json-rpc-body', code: -32_600, httpStatus: 400 }); + }); + + test('a missing body is rejected', () => { + const outcome = classifyInboundRequest({ httpMethod: 'POST' }); + expect(outcome).toMatchObject({ kind: 'reject', cell: 'invalid-json-rpc-body', code: -32_600 }); + }); +}); + +describe('modern-only (strict) rejection mapping', () => { + const SUPPORTED = [MODERN_REVISION]; + const legacyRoute = (body: unknown, headers: { protocolVersion?: string } = {}): InboundLegacyRoute => { + const outcome = classifyInboundRequest(post(body, headers)); + expect(outcome.kind).toBe('legacy'); + return outcome as InboundLegacyRoute; + }; + + test('an envelope-less request answers the unsupported-protocol-version error with the supported list', () => { + const rejectionOutcome = modernOnlyStrictRejection(legacyRoute(legacyToolsList()), SUPPORTED); + expect(rejectionOutcome).toMatchObject({ + cell: 'modern-only-missing-envelope', + httpStatus: 400, + code: -32_004, + settled: true, + data: { supported: SUPPORTED, requested: LATEST_PROTOCOL_VERSION } + }); + expect(rejectionOutcome?.message).toContain('Unsupported protocol version'); + }); + + test('an envelope-less initialize names the version it requested', () => { + const rejectionOutcome = modernOnlyStrictRejection(legacyRoute(initializeRequest('2025-06-18')), SUPPORTED); + expect(rejectionOutcome).toMatchObject({ code: -32_004, data: { supported: SUPPORTED, requested: '2025-06-18' } }); + }); + + test('an envelope-less request echoes the protocol-version header it sent', () => { + const rejectionOutcome = modernOnlyStrictRejection(legacyRoute(legacyToolsList(), { protocolVersion: '2025-03-26' }), SUPPORTED); + expect(rejectionOutcome).toMatchObject({ code: -32_004, data: { requested: '2025-03-26' } }); + }); + + test('batch and response POSTs are invalid requests on a modern-only endpoint', () => { + expect(modernOnlyStrictRejection(legacyRoute([legacyToolsList()]), SUPPORTED)).toMatchObject({ code: -32_600, httpStatus: 400 }); + expect(modernOnlyStrictRejection(legacyRoute({ jsonrpc: '2.0', id: 1, result: {} }), SUPPORTED)).toMatchObject({ + code: -32_600, + httpStatus: 400 + }); + }); + + test('non-POST methods are not allowed on a modern-only endpoint', () => { + const route = classifyInboundRequest({ httpMethod: 'GET' }) as InboundLegacyRoute; + expect(modernOnlyStrictRejection(route, SUPPORTED)).toMatchObject({ + httpStatus: 405, + code: -32_000, + message: 'Method not allowed.' + }); + }); + + test('legacy-classified notifications are accepted-and-dropped (no rejection body)', () => { + const route = classifyInboundRequest(post(notification())) as InboundLegacyRoute; + expect(modernOnlyStrictRejection(route, SUPPORTED)).toBeUndefined(); + }); +}); diff --git a/packages/core/test/shared/inboundLadderCellSheet.test.ts b/packages/core/test/shared/inboundLadderCellSheet.test.ts new file mode 100644 index 0000000000..28ddfa7375 --- /dev/null +++ b/packages/core/test/shared/inboundLadderCellSheet.test.ts @@ -0,0 +1,391 @@ +/** + * The inbound validation-ladder cell sheet. + * + * Each row names one ladder cell, whether its outcome is pinned or + * parameterized, the conformance scenarios that exercise it (where one + * exists), and the expected outcome. Pinned rows assert exact codes and HTTP + * statuses; parameterized rows assert the outcome class and that the emitted + * code is the documented provisional value drawn from the candidate set — + * those cells are re-derived when a published conformance release settles the + * disputed assignments (see the note in + * `test/conformance/expected-failures.yaml`). + * + * Cells evaluated at protocol dispatch (the era registry gate, per-method + * params, capability assertion) are listed for ordering and status mapping + * only; their end-to-end HTTP assertions live with the per-request server + * transport tests in the server package. + */ +import { describe, expect, test } from 'vitest'; + +import type { InboundHttpRequest, InboundLadderRejection } from '../../src/shared/inboundClassification.js'; +import { + classifyInboundRequest, + httpStatusForErrorCode, + INBOUND_VALIDATION_LADDER, + LADDER_ERROR_HTTP_STATUS, + modernOnlyStrictRejection, + PROVISIONAL_CROSS_CHECK_MISMATCH_CODE +} from '../../src/shared/inboundClassification.js'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../src/types/constants.js'; + +const MODERN_REVISION = '2026-07-28'; +const MISMATCH_CODE_CANDIDATES = [-32_001, -32_602, -32_004]; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'cell-sheet-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +const enveloped = (method: string, params: Record = {}) => ({ + jsonrpc: '2.0', + id: 1, + method, + params: { ...params, _meta: ENVELOPE } +}); +const bare = (method: string, params: Record = {}) => ({ jsonrpc: '2.0', id: 1, method, params }); +const post = (body: unknown, headers: { protocolVersion?: string; mcpMethod?: string } = {}): InboundHttpRequest => ({ + httpMethod: 'POST', + body, + ...(headers.protocolVersion !== undefined && { protocolVersionHeader: headers.protocolVersion }), + ...(headers.mcpMethod !== undefined && { mcpMethodHeader: headers.mcpMethod }) +}); + +interface SheetRow { + /** Stable cell identifier (matches `InboundLadderRejection.cell` for rejection cells). */ + cell: string; + /** Pinned cells assert exact outcomes; parameterized cells assert the provisional outcome + candidate-set membership. */ + status: 'pinned' | 'parameterized'; + /** Conformance scenarios exercising the cell, where one exists in the published referee. */ + conformance: readonly string[]; + /** The classifier input. */ + input: InboundHttpRequest; + /** Strict (modern-only) mapping applies: the legacy route is mapped through `modernOnlyStrictRejection`. */ + strict?: boolean; + /** The expected outcome for routing cells. */ + route?: 'legacy' | 'modern'; + /** The expected rejection (exact for pinned cells; for parameterized cells `code` is the provisional value). */ + reject?: Partial; + /** Why the cell behaves the way it does. */ + rationale: string; +} + +const SHEET: readonly SheetRow[] = [ + /* --- Routing cells (pinned) --------------------------------------------------- */ + { + cell: 'modern-enveloped-request', + status: 'pinned', + conformance: ['server-stateless'], + input: post(enveloped('tools/call', { name: 'echo', arguments: {} }), { protocolVersion: MODERN_REVISION }), + route: 'modern', + rationale: 'A request carrying the per-request envelope claim is modern-era traffic.' + }, + { + cell: 'modern-enveloped-request-header-stripped', + status: 'pinned', + conformance: ['server-stateless'], + input: post(enveloped('tools/call', { name: 'echo', arguments: {} })), + route: 'modern', + rationale: 'Body-primary classification: a proxy stripping the protocol-version header must not change the era.' + }, + { + cell: 'legacy-claimless-request', + status: 'pinned', + conformance: [], + input: post(bare('tools/list'), { protocolVersion: '2025-06-18' }), + route: 'legacy', + rationale: 'A request without an envelope claim is legacy traffic and is never classified.' + }, + { + cell: 'legacy-initialize', + status: 'pinned', + conformance: [], + input: post(bare('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '1' } })), + route: 'legacy', + rationale: 'initialize is the legacy handshake by definition; the modern era has no initialize.' + }, + { + cell: 'legacy-method-routed-get', + status: 'pinned', + conformance: [], + input: { httpMethod: 'GET' }, + route: 'legacy', + rationale: 'GET/DELETE are body-less 2025-era session operations; the modern era is POST-only.' + }, + { + cell: 'legacy-notification-stripped-header', + status: 'pinned', + conformance: [], + input: post({ jsonrpc: '2.0', method: 'notifications/initialized' }), + route: 'legacy', + rationale: + 'A notification without a body claim or a modern header stays legacy traffic (dual mode routes it; strict mode accepts and drops it).' + }, + { + cell: 'modern-notification-by-header', + status: 'pinned', + conformance: ['http-header-validation'], + input: post({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 1 } }, { protocolVersion: MODERN_REVISION }), + route: 'modern', + rationale: 'Notifications carry no body claim, so the modern protocol-version header is determinative for them.' + }, + { + cell: 'legacy-batch', + status: 'pinned', + conformance: [], + input: post([bare('tools/list')]), + route: 'legacy', + rationale: 'All-legacy arrays go to legacy serving unchanged; a single-element array is still an array.' + }, + { + cell: 'legacy-response-post', + status: 'pinned', + conformance: [], + input: post({ jsonrpc: '2.0', id: 5, result: {} }), + route: 'legacy', + rationale: 'Posted responses are 2025-era session traffic (replies to server-initiated requests).' + }, + + /* --- Edge rejection cells (pinned) -------------------------------------------- */ + { + cell: 'envelope-invalid', + status: 'pinned', + conformance: ['server-stateless'], + input: post({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION } } }), + reject: { rung: 'envelope', httpStatus: 400, code: -32_602, settled: true }, + rationale: 'A present claim with a malformed envelope is invalid params naming the key — never a silent legacy fallthrough.' + }, + { + cell: 'batch-with-modern-element', + status: 'pinned', + conformance: [], + input: post([bare('tools/list'), enveloped('tools/call', { name: 'echo', arguments: {} })]), + reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, + rationale: 'Element-wise batch rule: one modern element makes the array unservable on either path.' + }, + { + cell: 'batch-with-invalid-element', + status: 'pinned', + conformance: [], + input: post([bare('tools/list'), { nonsense: true }]), + reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, + rationale: 'Element-wise batch rule: invalid elements are rejected rather than partially served.' + }, + { + cell: 'invalid-json-rpc-body', + status: 'pinned', + conformance: [], + input: post({ hello: 'world' }), + reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, + rationale: 'A POST body that is not a JSON-RPC message is an invalid request.' + }, + + /* --- Modern-only (strict) cells (pinned) --------------------------------------- */ + { + cell: 'modern-only-missing-envelope', + status: 'pinned', + conformance: ['server-stateless'], + input: post(bare('tools/list')), + strict: true, + reject: { rung: 'era-classification', httpStatus: 400, code: -32_004, settled: true }, + rationale: + 'A modern-only endpoint answers envelope-less requests with the unsupported-protocol-version error and its supported list. ' + + 'This cell shares its numeric code with the disputed mismatch family but is itself settled.' + }, + { + cell: 'modern-only-missing-envelope-initialize', + status: 'pinned', + conformance: ['server-stateless'], + input: post(bare('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '1' } })), + strict: true, + reject: { rung: 'era-classification', httpStatus: 400, code: -32_004, settled: true }, + rationale: 'An envelope-less initialize on a modern-only endpoint is answered with the version error naming both sides.' + }, + { + cell: 'modern-only-method-not-allowed', + status: 'pinned', + conformance: [], + input: { httpMethod: 'DELETE' }, + strict: true, + reject: { rung: 'http-method', httpStatus: 405, code: -32_000, settled: true }, + rationale: 'Without legacy serving configured there is nothing to route GET/DELETE to.' + }, + { + cell: 'modern-only-batch-not-supported', + status: 'pinned', + conformance: [], + input: post([bare('tools/list')]), + strict: true, + reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, + rationale: 'Batches are not part of the modern wire shape.' + }, + { + cell: 'modern-only-response-post', + status: 'pinned', + conformance: [], + input: post({ jsonrpc: '2.0', id: 5, result: {} }), + strict: true, + reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, + rationale: 'There is no server-to-client request channel on the modern era, so posted responses are invalid requests.' + }, + + /* --- Parameterized cells (disputed error-code assignments) --------------------- */ + { + cell: 'header-body-version-mismatch', + status: 'parameterized', + conformance: ['http-header-validation', 'http-custom-header-server-validation'], + input: post(enveloped('tools/call', { name: 'echo', arguments: {} }), { protocolVersion: '2025-06-18' }), + reject: { rung: 'era-classification', httpStatus: 400, settled: false }, + rationale: 'Header/body protocol-version disagreement; the exact code is still under discussion upstream.' + }, + { + cell: 'modern-header-without-claim', + status: 'parameterized', + conformance: ['http-header-validation'], + input: post(bare('tools/list'), { protocolVersion: MODERN_REVISION }), + reject: { rung: 'era-classification', httpStatus: 400, settled: false }, + rationale: 'A modern header on a claim-less body is a disagreement, not an upgrade; code pending upstream settlement.' + }, + { + cell: 'initialize-with-modern-header', + status: 'parameterized', + conformance: ['http-header-validation'], + input: post(bare('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '1' } }), { + protocolVersion: MODERN_REVISION + }), + reject: { rung: 'era-classification', httpStatus: 400, settled: false }, + rationale: 'initialize classifies legacy; a modern header on it is the same disagreement family.' + }, + { + cell: 'method-header-mismatch', + status: 'parameterized', + conformance: ['http-custom-header-server-validation'], + input: post(enveloped('tools/call', { name: 'echo', arguments: {} }), { + protocolVersion: MODERN_REVISION, + mcpMethod: 'tools/list' + }), + reject: { rung: 'era-classification', httpStatus: 400, settled: false }, + rationale: 'The Mcp-Method header must describe the body it accompanies; the rejection code is pending upstream settlement.' + }, + { + cell: 'multi-fault-mismatched-claim-and-malformed-envelope', + status: 'parameterized', + conformance: ['server-stateless', 'http-header-validation'], + // The claim names a different version than the header AND the envelope + // is missing required keys: today the envelope rung answers (the + // mismatch is only checked on a valid envelope), so the emitted code is + // -32602 — but the precedence between the era-classification and + // envelope rungs for multi-fault requests is part of the disputed set. + input: post( + { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION } } }, + { + protocolVersion: '2025-06-18' + } + ), + reject: { httpStatus: 400 }, + rationale: + 'Multi-fault precedence between the version error and invalid params is not settled upstream; asserted as candidate-set membership only.' + } +]; + +describe('inbound validation-ladder cell sheet', () => { + const SUPPORTED = [MODERN_REVISION]; + + test.each(SHEET)('$cell', row => { + let outcome = classifyInboundRequest(row.input); + if (row.strict) { + expect(outcome.kind).toBe('legacy'); + if (outcome.kind !== 'legacy') return; + const mapped = modernOnlyStrictRejection(outcome, SUPPORTED); + expect(mapped).toBeDefined(); + outcome = mapped!; + } + + if (row.route !== undefined) { + expect(outcome.kind).toBe(row.route); + if (row.route === 'legacy') { + // Legacy routes never carry a classification (hand-wired and + // legacy traffic is never classified). + expect('classification' in outcome).toBe(false); + } + return; + } + + expect(outcome.kind).toBe('reject'); + if (outcome.kind !== 'reject') return; + + if (row.status === 'pinned') { + expect(outcome).toMatchObject(row.reject ?? {}); + } else { + // Parameterized: outcome class and provisional code only — the + // exact assignment is re-derived from a future conformance pin. + if (row.reject?.rung !== undefined) expect(outcome.rung).toBe(row.reject.rung); + if (row.reject?.httpStatus !== undefined) expect(outcome.httpStatus).toBe(row.reject.httpStatus); + expect(MISMATCH_CODE_CANDIDATES).toContain(outcome.code); + if (row.reject?.settled !== undefined) { + expect(outcome.settled).toBe(row.reject.settled); + expect(outcome.code).toBe(PROVISIONAL_CROSS_CHECK_MISMATCH_CODE); + } + } + }); + + test('every cell id is unique and every parameterized cell is marked unsettled or candidate-bound', () => { + const ids = SHEET.map(row => row.cell); + expect(new Set(ids).size).toBe(ids.length); + for (const row of SHEET.filter(candidate => candidate.status === 'parameterized')) { + expect(row.reject).toBeDefined(); + } + }); +}); + +describe('the validation ladder as data', () => { + test('rungs are uniquely named and strictly ordered', () => { + const orders = INBOUND_VALIDATION_LADDER.map(rung => rung.order); + expect(orders.toSorted((a, b) => a - b)).toEqual(orders); + expect(new Set(orders).size).toBe(orders.length); + expect(new Set(INBOUND_VALIDATION_LADDER.map(rung => rung.rung)).size).toBe(INBOUND_VALIDATION_LADDER.length); + }); + + test('the edge rungs precede the dispatch rungs', () => { + const lastEdge = Math.max(...INBOUND_VALIDATION_LADDER.filter(rung => rung.evaluatedAt === 'edge').map(rung => rung.order)); + const firstDispatch = Math.min( + ...INBOUND_VALIDATION_LADDER.filter(rung => rung.evaluatedAt === 'dispatch').map(rung => rung.order) + ); + expect(lastEdge).toBeLessThan(firstDispatch); + }); + + test('method existence outranks parameter validity in the rung order', () => { + const methodRegistry = INBOUND_VALIDATION_LADDER.find(rung => rung.rung === 'method-registry'); + const requestParams = INBOUND_VALIDATION_LADDER.find(rung => rung.rung === 'request-params'); + expect(methodRegistry!.order).toBeLessThan(requestParams!.order); + }); +}); + +describe('HTTP status mapping for ladder-originated errors (origin-keyed)', () => { + test('the table maps exactly the ladder-originated codes', () => { + expect(LADDER_ERROR_HTTP_STATUS).toEqual({ + [-32_601]: 404, + [-32_004]: 400, + [-32_003]: 400, + [-32_001]: 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_004, -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_004, 'ladder')).toBe(400); + expect(httpStatusForErrorCode(-32_003, 'ladder')).toBe(400); + expect(httpStatusForErrorCode(-32_001, 'ladder')).toBe(400); + }); +}); From 310eb7e5face11cece4338b5cc54950a696bf116 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 15 Jun 2026 20:31:54 +0000 Subject: [PATCH 2/8] feat(server): add a single-exchange per-request HTTP server transport PerRequestHTTPServerTransport serves exactly one already-classified JSON-RPC message and produces exactly one HTTP response: 202 with no body for notifications, a single JSON body for requests with no streamed output, or a lazily-opened SSE stream when the handler emits related messages before its result (the stream carries those messages, then the terminal result, then closes). Pre-handler rejections whose codes are in the ladder status table are answered with the mapped HTTP status; handler-produced errors stay in-band on 200. The transport is constructed already-classified and attaches the classification, the original request, and caller-supplied auth info to every delivered message; auth info is strictly pass-through and never derived from request headers. Client disconnects cancel the exchange: an aborted request signal or a cancelled response stream closes the transport, which aborts the in-flight handler through the existing close chain. No session ids, resumability machinery, or header validation are carried over from the session-oriented transport, and the protocol dispatch layer is unchanged. The transport stays internal to the package; the public entry point that constructs it per request lands separately. --- .../server/src/server/perRequestTransport.ts | 365 ++++++++++++++++++ .../test/server/perRequestStreaming.test.ts | 218 +++++++++++ .../test/server/perRequestTransport.test.ts | 333 ++++++++++++++++ 3 files changed, 916 insertions(+) create mode 100644 packages/server/src/server/perRequestTransport.ts create mode 100644 packages/server/test/server/perRequestStreaming.test.ts create mode 100644 packages/server/test/server/perRequestTransport.test.ts diff --git a/packages/server/src/server/perRequestTransport.ts b/packages/server/src/server/perRequestTransport.ts new file mode 100644 index 0000000000..0d77d091c6 --- /dev/null +++ b/packages/server/src/server/perRequestTransport.ts @@ -0,0 +1,365 @@ +/** + * A single-exchange, per-request HTTP server transport for modern-era + * (protocol revision 2026-07-28) serving. + * + * One transport instance serves exactly one already-classified inbound + * JSON-RPC message and produces exactly one HTTP `Response`: + * + * - a `202` with no body for notifications, + * - a single JSON body for requests whose handler produces no streamed + * output, or + * - a lazily-opened SSE stream when the handler emits related messages + * (notifications or server-to-client requests) before its result — the + * stream carries those messages and finally the terminal result, then + * closes. + * + * The transport is constructed already-classified: the entry parses and + * classifies the request body exactly once and hands the classification in via + * the constructor; the transport attaches it (together with the original + * request and any caller-provided auth info) to every message it delivers, and + * the protocol layer validates it against the serving instance's negotiated + * era. `authInfo` is strictly pass-through — it is never derived from the + * inbound request's headers here. + * + * Deliberately NOT carried over from the session-oriented streamable HTTP + * transport: session ids and session headers, resumability (event ids, + * priming events, `Last-Event-ID` replay, retry hints), the standalone GET + * 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). + */ +import type { + AuthInfo, + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + MessageClassification, + MessageExtraInfo, + RequestId, + Transport, + TransportSendOptions +} from '@modelcontextprotocol/core'; +import { + isJSONRPCErrorResponse, + isJSONRPCRequest, + isJSONRPCResultResponse, + LADDER_ERROR_HTTP_STATUS, + SdkError, + SdkErrorCode +} from '@modelcontextprotocol/core'; + +/** + * How the transport shapes its HTTP response for a request: + * + * - `auto` (default): answer with a single JSON body unless the handler emits + * a related message before its result, in which case the response upgrades + * to an SSE stream. + * - `sse`: open the SSE stream immediately. + * - `json`: never stream; related messages other than the terminal response + * are dropped. + */ +export type PerRequestResponseMode = 'auto' | 'sse' | 'json'; + +/** Constructor options for {@linkcode PerRequestHTTPServerTransport}. */ +export interface PerRequestHTTPServerTransportOptions { + /** The edge classification of the message this transport will serve. */ + classification: MessageClassification; + /** Response shaping for the exchange; defaults to `auto`. */ + responseMode?: PerRequestResponseMode; +} + +/** Per-exchange context handed to {@linkcode PerRequestHTTPServerTransport.handleMessage}. */ +export interface PerRequestMessageExtra { + /** + * The original HTTP request. Used for handler context and, when the + * runtime provides an abort signal on it, to cancel the exchange when the + * client disconnects. + */ + request?: globalThis.Request; + /** + * Validated authentication information supplied by the caller. Strictly + * pass-through: the transport never populates this from request headers. + */ + authInfo?: AuthInfo; +} + +interface DeferredResponse { + promise: Promise; + resolve: (response: Response) => void; + reject: (error: Error) => void; + settled: boolean; +} + +interface SseSink { + controller: ReadableStreamDefaultController; + encoder: InstanceType; + closed: boolean; +} + +/** + * The per-request micro-transport: a real, connected `Transport` whose whole + * lifetime is one HTTP exchange. See the module documentation for the + * response shapes it produces. + */ +export class PerRequestHTTPServerTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: T, extra?: MessageExtraInfo) => void; + + private readonly _classification: MessageClassification; + private readonly _responseMode: PerRequestResponseMode; + + private _started = false; + private _used = false; + private _closed = false; + private _terminalDelivered = false; + private _requestId?: RequestId; + private _deferredResponse?: DeferredResponse; + private _sse?: SseSink; + private _abortCleanup?: () => void; + + constructor(options: PerRequestHTTPServerTransportOptions) { + this._classification = options.classification; + this._responseMode = options.responseMode ?? 'auto'; + } + + async start(): Promise { + if (this._started) { + throw new Error('PerRequestHTTPServerTransport is already started'); + } + this._started = true; + } + + /** + * Serves the single exchange: delivers the classified message to the + * connected server instance and resolves with the HTTP response. + * + * Throws when called a second time (the transport is strictly + * single-use), or before a server has been connected to the transport. + * The returned promise rejects with a connection-closed error when the + * transport is closed before a response was produced (for example because + * the client disconnected). + */ + async handleMessage(message: JSONRPCRequest | JSONRPCNotification, extra?: PerRequestMessageExtra): Promise { + if (this._used) { + throw new Error('PerRequestHTTPServerTransport serves exactly one exchange; construct a new transport per request'); + } + this._used = true; + if (!this._started || this.onmessage === undefined) { + throw new Error('PerRequestHTTPServerTransport is not connected: connect a server to this transport before handling a message'); + } + if (this._closed) { + throw new Error('PerRequestHTTPServerTransport is closed'); + } + + const signal = extra?.request?.signal; + if (signal?.aborted) { + await this.close(); + throw new SdkError(SdkErrorCode.ConnectionClosed, 'The request was aborted before it could be handled'); + } + + // authInfo is strictly pass-through from the caller; it is never + // derived from the inbound request's headers. + const messageExtra: MessageExtraInfo = { + classification: this._classification, + ...(extra?.request !== undefined && { request: extra.request }), + ...(extra?.authInfo !== undefined && { authInfo: extra.authInfo }) + }; + + if (isJSONRPCRequest(message)) { + this._requestId = message.id; + + let resolve!: (response: Response) => void; + let reject!: (error: Error) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + this._deferredResponse = { promise, resolve, reject, settled: false }; + + if (signal !== undefined) { + const onAbort = () => void this.close(); + signal.addEventListener('abort', onAbort, { once: true }); + this._abortCleanup = () => signal.removeEventListener('abort', onAbort); + } + + if (this._responseMode === 'sse') { + this.upgradeToSse(); + } + + this.onmessage(message, messageExtra); + return promise; + } + + // Notifications never get a JSON-RPC response: deliver the message and + // acknowledge the POST with 202 and no body. + this.onmessage(message, messageExtra); + return new Response(null, { status: 202 }); + } + + async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { + if (this._closed) { + // The exchange is over; late writes are dropped. + return; + } + + const isResponse = isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message); + const relatedId = isResponse ? (message as { id: RequestId }).id : options?.relatedRequestId; + + if (this._requestId === undefined || relatedId === undefined || relatedId !== this._requestId) { + if (isResponse) { + this.onerror?.(new Error(`Received a response for an unknown request id: ${String((message as { id?: unknown }).id)}`)); + } + // Messages unrelated to the single in-flight request have nowhere + // to go on a per-request exchange (there is no session-wide + // stream); they are dropped. + return; + } + + if (isResponse) { + if (this._terminalDelivered) { + return; + } + this._terminalDelivered = true; + + if (this._sse !== undefined) { + // Finalize the stream: serialize the terminal result onto it + // after everything already enqueued, then close. + this.writeMessageFrame(message); + this.finalizeStream(); + return; + } + + // Single JSON body. Errors produced before a handler ran (the + // protocol-version mismatch, the era registry gate, a missing + // handler) carry the codes in the ladder status table and are + // answered with the mapped HTTP status; every other error — + // whatever its code — stays in-band on HTTP 200. + const status = isJSONRPCErrorResponse(message) + ? (LADDER_ERROR_HTTP_STATUS[(message as JSONRPCErrorResponse).error.code] ?? 200) + : 200; + this.settleResponse(Response.json(message, { status, headers: { 'Content-Type': 'application/json' } })); + queueMicrotask(() => void this.close()); + return; + } + + // A message related to the in-flight request that is not its terminal + // response: a mid-call notification or a server-to-client request + // emitted by the handler. + if (this._responseMode === 'json') { + // JSON responses cannot carry mid-call messages; they are dropped. + return; + } + if (this._sse === undefined) { + this.upgradeToSse(); + } + this.writeMessageFrame(message); + } + + /** + * Writes an SSE comment frame (a keep-alive heartbeat). Dropped when the + * exchange is not currently streaming. + */ + writeCommentFrame(comment: string): void { + if (this._closed || this._sse === undefined || this._sse.closed) { + return; + } + const frame = comment + .split('\n') + .map(line => `: ${line}`) + .join('\n'); + this.writeFrame(`${frame}\n\n`); + } + + async close(): Promise { + if (this._closed) { + return; + } + this._closed = true; + + this._abortCleanup?.(); + this._abortCleanup = undefined; + + if (this._sse !== undefined && !this._sse.closed) { + this._sse.closed = true; + try { + this._sse.controller.close(); + } catch { + // The stream was already closed or cancelled by the consumer. + } + } + + if (this._deferredResponse !== undefined && !this._deferredResponse.settled) { + this._deferredResponse.settled = true; + this._deferredResponse.reject(new SdkError(SdkErrorCode.ConnectionClosed, 'Connection closed before a response was produced')); + } + + this.onclose?.(); + } + + private settleResponse(response: Response): void { + if (this._deferredResponse === undefined || this._deferredResponse.settled) { + return; + } + this._deferredResponse.settled = true; + this._deferredResponse.resolve(response); + } + + private upgradeToSse(): void { + let controller!: ReadableStreamDefaultController; + const readable = new ReadableStream({ + start: streamController => { + controller = streamController; + }, + cancel: () => { + // The client went away mid-stream: tear the exchange down, + // which aborts the in-flight handler through the connected + // server's close chain. + void this.close(); + } + }); + this._sse = { controller, encoder: new TextEncoder(), closed: false }; + + this.settleResponse( + new Response(readable, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + // Disable proxy buffering so streamed messages are + // delivered as they are written. + 'X-Accel-Buffering': 'no' + } + }) + ); + } + + private finalizeStream(): void { + if (this._sse !== undefined && !this._sse.closed) { + this._sse.closed = true; + try { + this._sse.controller.close(); + } catch { + // The stream was already cancelled by the consumer. + } + } + queueMicrotask(() => void this.close()); + } + + private writeMessageFrame(message: JSONRPCMessage): void { + this.writeFrame(`event: message\ndata: ${JSON.stringify(message)}\n\n`); + } + + private writeFrame(frame: string): void { + if (this._sse === undefined || this._sse.closed) { + return; + } + try { + this._sse.controller.enqueue(this._sse.encoder.encode(frame)); + } catch (error) { + this.onerror?.(new Error(`Failed to write to the response stream: ${error}`)); + } + } +} diff --git a/packages/server/test/server/perRequestStreaming.test.ts b/packages/server/test/server/perRequestStreaming.test.ts new file mode 100644 index 0000000000..ea2ec61d57 --- /dev/null +++ b/packages/server/test/server/perRequestStreaming.test.ts @@ -0,0 +1,218 @@ +/** + * Per-request streaming behavior: the lazy JSON-to-SSE upgrade, sink + * discipline (write order, drain-before-finalize, post-close drops), the + * forced response modes the entry-level knob will plug into, comment-frame + * support, and disconnect-as-cancellation. + */ +import type { CallToolResult, JSONRPCRequest, MessageClassification, ServerContext } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + setNegotiatedProtocolVersion +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import type { PerRequestResponseMode } from '../../src/server/perRequestTransport.js'; +import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport.js'; +import { Server } from '../../src/server/server.js'; + +const MODERN_REVISION = '2026-07-28'; +const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION }; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'streaming-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +const toolsCall = (id = 1): JSONRPCRequest => + ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: 'echo', arguments: {}, _meta: ENVELOPE } + }) as JSONRPCRequest; + +const progressNotification = (progress: number) => ({ + method: 'notifications/progress' as const, + params: { progressToken: 'stream-test', progress } +}); + +interface StreamingSetup { + server: Server; + transport: PerRequestHTTPServerTransport; +} + +async function setup( + handler: (ctx: ServerContext) => Promise, + responseMode?: PerRequestResponseMode +): Promise { + const server = new Server({ name: 'streaming-test', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/call', async (_request, ctx) => handler(ctx)); + setNegotiatedProtocolVersion(server, MODERN_REVISION); + const transport = new PerRequestHTTPServerTransport({ + classification: MODERN, + ...(responseMode !== undefined && { responseMode }) + }); + await server.connect(transport); + return { server, transport }; +} + +/** SSE frames of a fully-drained response body, split on the blank-line separator. */ +async function sseFrames(response: Response): Promise { + const text = await response.text(); + return text + .split('\n\n') + .map(frame => frame.trim()) + .filter(frame => frame.length > 0); +} + +const dataOf = (frame: string): unknown => { + const dataLine = frame.split('\n').find(line => line.startsWith('data: ')); + return dataLine === undefined ? undefined : JSON.parse(dataLine.slice('data: '.length)); +}; + +describe('lazy upgrade matrix', () => { + it('answers a handler with no streamed output as a single JSON body', async () => { + const { transport } = await setup(async () => ({ content: [{ type: 'text', text: 'plain' }] })); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + expect(response.headers.get('x-accel-buffering')).toBeNull(); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('plain'); + }); + + it('upgrades to SSE on the first related notification', async () => { + const { transport } = await setup(async ctx => { + await ctx.mcpReq.notify(progressNotification(1)); + return { content: [{ type: 'text', text: 'streamed' }] }; + }); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + expect(response.headers.get('cache-control')).toBe('no-cache'); + expect(response.headers.get('x-accel-buffering')).toBe('no'); + + const frames = await sseFrames(response); + expect(frames).toHaveLength(2); + expect(dataOf(frames[0]!)).toMatchObject({ method: 'notifications/progress' }); + expect(dataOf(frames[1]!)).toMatchObject({ id: 1, result: { content: [{ type: 'text', text: 'streamed' }] } }); + }); + + it('drains every streamed message before the terminal result and then ends the stream', async () => { + const { transport } = await setup(async ctx => { + await ctx.mcpReq.notify(progressNotification(1)); + await ctx.mcpReq.notify(progressNotification(2)); + await ctx.mcpReq.notify(progressNotification(3)); + return { content: [{ type: 'text', text: 'done' }] }; + }); + const response = await transport.handleMessage(toolsCall()); + const frames = await sseFrames(response); + expect(frames).toHaveLength(4); + const progressValues = frames.slice(0, 3).map(frame => (dataOf(frame) as { params: { progress: number } }).params.progress); + expect(progressValues).toEqual([1, 2, 3]); + expect(dataOf(frames[3]!)).toMatchObject({ result: { content: [{ type: 'text', text: 'done' }] } }); + }); + + it('emits no resumability bytes: no event ids, no retry hints, no priming events', async () => { + const { transport } = await setup(async ctx => { + await ctx.mcpReq.notify(progressNotification(1)); + return { content: [] }; + }); + const response = await transport.handleMessage(toolsCall()); + const text = await response.text(); + expect(text).not.toMatch(/^id:/m); + expect(text).not.toMatch(/^retry:/m); + expect(response.headers.get('mcp-session-id')).toBeNull(); + }); + + it('drops writes after the exchange is closed', async () => { + const { transport } = await setup(async () => ({ content: [] })); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + await transport.close(); + await expect(transport.send(progressNotification(9) as never, { relatedRequestId: 1 })).resolves.toBeUndefined(); + }); +}); + +describe('forced response modes (the seam the entry-level knob plugs into)', () => { + it('sse mode opens the stream immediately, even with no streamed output', async () => { + const { transport } = await setup(async () => ({ content: [{ type: 'text', text: 'eager' }] }), 'sse'); + const response = await transport.handleMessage(toolsCall()); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + const frames = await sseFrames(response); + expect(frames).toHaveLength(1); + expect(dataOf(frames[0]!)).toMatchObject({ result: { content: [{ type: 'text', text: 'eager' }] } }); + }); + + it('json mode never upgrades and drops mid-call notifications', async () => { + const { transport } = await setup(async ctx => { + await ctx.mcpReq.notify(progressNotification(1)); + await ctx.mcpReq.notify(progressNotification(2)); + return { content: [{ type: 'text', text: 'json-only' }] }; + }, 'json'); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('json-only'); + // The notifications were dropped, not buffered into the body. + expect(JSON.stringify(body)).not.toContain('notifications/progress'); + }); +}); + +describe('comment frames', () => { + it('writes comment frames into an open stream and drops them otherwise', async () => { + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + const { transport } = await setup(async () => { + await gate; + return { content: [] }; + }, 'sse'); + + const responsePromise = transport.handleMessage(toolsCall()); + // The stream is open (sse mode settles immediately); a comment frame + // written now must be delivered to the consumer. + transport.writeCommentFrame('keep-alive'); + release(); + const response = await responsePromise; + const text = await response.text(); + expect(text).toContain(': keep-alive'); + + // After the exchange completed (and the transport closed itself), + // comment frames are dropped silently. + transport.writeCommentFrame('late'); + expect(text).not.toContain(': late'); + }); +}); + +describe('disconnect is cancellation', () => { + it('cancelling the SSE stream aborts the in-flight handler', async () => { + let observedSignal: AbortSignal | undefined; + let abortObserved!: () => void; + const aborted = new Promise(resolve => { + abortObserved = resolve; + }); + const { transport } = await setup(async ctx => { + observedSignal = ctx.mcpReq.signal; + ctx.mcpReq.signal.addEventListener('abort', () => abortObserved(), { once: true }); + await ctx.mcpReq.notify(progressNotification(1)); + await aborted; + return { content: [] }; + }); + const response = await transport.handleMessage(toolsCall()); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + + const reader = response.body!.getReader(); + await reader.read(); + // The client goes away: cancelling the response stream tears the + // exchange down and aborts the handler's signal. + await reader.cancel(); + await aborted; + expect(observedSignal?.aborted).toBe(true); + }); +}); diff --git a/packages/server/test/server/perRequestTransport.test.ts b/packages/server/test/server/perRequestTransport.test.ts new file mode 100644 index 0000000000..5e07beffa7 --- /dev/null +++ b/packages/server/test/server/perRequestTransport.test.ts @@ -0,0 +1,333 @@ +/** + * The per-request HTTP server transport: single-exchange contract, the + * classification handoff into protocol dispatch, HTTP status mapping for + * pre-handler rejections, auth-info pass-through, and the close/teardown + * chain. + */ +import type { CallToolResult, JSONRPCNotification, JSONRPCRequest, MessageClassification, ServerContext } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + ProtocolError, + setNegotiatedProtocolVersion +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport.js'; +import { Server } from '../../src/server/server.js'; + +const MODERN_REVISION = '2026-07-28'; +const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION }; +const LEGACY: MessageClassification = { era: 'legacy' }; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'per-request-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +// `meta: null` builds an envelope-less request; the default is the full envelope. +const toolsCall = (id = 1, meta: Record | null = ENVELOPE): JSONRPCRequest => + ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: 'echo', arguments: {}, ...(meta !== null && { _meta: meta }) } + }) as JSONRPCRequest; + +const envelopedRequest = (method: string, id = 1): JSONRPCRequest => + ({ jsonrpc: '2.0', id, method, params: { _meta: ENVELOPE } }) as JSONRPCRequest; + +interface ServerSetup { + server: Server; + lastCtx: () => ServerContext | undefined; +} + +function modernServer(options: { toolsCallHandler?: (ctx: ServerContext) => Promise } = {}): ServerSetup { + const server = new Server({ name: 'per-request-test', version: '1.0.0' }, { capabilities: { tools: {} } }); + let captured: ServerContext | undefined; + const defaultHandler = async (): Promise => ({ content: [{ type: 'text', text: 'served' }] }); + server.setRequestHandler('tools/call', async (_request, ctx) => { + captured = ctx; + return (options.toolsCallHandler ?? defaultHandler)(ctx); + }); + setNegotiatedProtocolVersion(server, MODERN_REVISION); + return { server, lastCtx: () => captured }; +} + +async function connectedTransport( + server: Server, + options?: ConstructorParameters[0] +): Promise { + const transport = new PerRequestHTTPServerTransport(options ?? { classification: MODERN }); + await server.connect(transport); + return transport; +} + +const errorOf = (body: unknown) => (body as { error?: { code: number; message: string; data?: unknown } }).error; + +describe('single-exchange contract', () => { + it('throws when a message is handled before a server is connected', async () => { + const transport = new PerRequestHTTPServerTransport({ classification: MODERN }); + await expect(transport.handleMessage(toolsCall())).rejects.toThrow(/not connected/); + }); + + it('serves exactly one exchange — a second handleMessage throws', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server); + const first = await transport.handleMessage(toolsCall()); + expect(first.status).toBe(200); + await expect(transport.handleMessage(toolsCall(2))).rejects.toThrow(/exactly one exchange/); + }); + + it('cannot be started twice', async () => { + const transport = new PerRequestHTTPServerTransport({ classification: MODERN }); + await transport.start(); + await expect(transport.start()).rejects.toThrow(/already started/); + }); + + it('answers notification POST bodies with 202 and no body', async () => { + const { server } = modernServer(); + let delivered: string | undefined; + server.fallbackNotificationHandler = async notification => { + delivered = notification.method; + }; + const transport = await connectedTransport(server); + const response = await transport.handleMessage({ jsonrpc: '2.0', method: 'demo/heartbeat' } as JSONRPCNotification); + expect(response.status).toBe(202); + expect(await response.text()).toBe(''); + await new Promise(resolve => setTimeout(resolve, 5)); + expect(delivered).toBe('demo/heartbeat'); + await transport.close(); + await server.close(); + }); +}); + +describe('classification handoff into dispatch', () => { + it('serves a modern-classified request on a modern-marked instance', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('served'); + }); + + it('answers legacy-classified traffic on a modern-marked instance with the protocol-version error and HTTP 400', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server, { classification: LEGACY }); + server.onerror = () => { + // The mismatch is also surfaced out of band; irrelevant here. + }; + const response = await transport.handleMessage(toolsCall(1, null)); + expect(response.status).toBe(400); + const error = errorOf(await response.json()); + expect(error?.code).toBe(-32_004); + expect(error?.data).toMatchObject({ requested: expect.any(String), supported: expect.any(Array) }); + }); + + it('answers modern-classified traffic on an unmarked (legacy) instance with the protocol-version error', async () => { + const server = new Server({ name: 'unmarked', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/call', async () => ({ content: [] })); + server.onerror = () => { + // The mismatch is also surfaced out of band; irrelevant here. + }; + const transport = await connectedTransport(server, { classification: MODERN }); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(400); + expect(errorOf(await response.json())?.code).toBe(-32_004); + }); +}); + +describe('HTTP status mapping', () => { + it('maps method-not-found for an era-removed method to HTTP 404', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server); + // `ping` exists on the 2025 era but has no entry on the 2026 registry. + const response = await transport.handleMessage(envelopedRequest('ping')); + expect(response.status).toBe(404); + expect(errorOf(await response.json())).toMatchObject({ code: -32_601, message: 'Method not found' }); + }); + + it('maps method-not-found for an unknown method with no handler to HTTP 404', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(envelopedRequest('definitely/unknown')); + expect(response.status).toBe(404); + expect(errorOf(await response.json())?.code).toBe(-32_601); + }); + + it('keeps handler-produced errors in-band on HTTP 200, whatever their code', async () => { + const { server } = modernServer({ + toolsCallHandler: async () => { + throw new ProtocolError(-32_002, 'resource missing'); + } + }); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(errorOf(await response.json())).toMatchObject({ code: -32_002, message: 'resource missing' }); + }); + + it('keeps handler-produced invalid-params errors in-band on HTTP 200 (never status-mapped)', async () => { + const { server } = modernServer({ + toolsCallHandler: async () => { + throw new ProtocolError(-32_602, 'bad arguments'); + } + }); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(errorOf(await response.json())?.code).toBe(-32_602); + }); + + it('keeps the dispatch-level envelope check in-band: only the edge classifier maps invalid params to 400', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server); + // Modern-classified request without the _meta envelope: the dispatch + // layer rejects it with invalid params; the transport does not turn + // that into an HTTP-level failure. + const response = await transport.handleMessage(toolsCall(1, null)); + expect(response.status).toBe(200); + expect(errorOf(await response.json())?.code).toBe(-32_602); + }); +}); + +describe('auth info is strictly pass-through', () => { + it('never derives authInfo from the inbound request headers', async () => { + const { server, lastCtx } = modernServer(); + const transport = await connectedTransport(server); + const request = new Request('http://localhost/mcp', { + method: 'POST', + headers: { authorization: 'Bearer super-secret-token', 'content-type': 'application/json' } + }); + const response = await transport.handleMessage(toolsCall(), { request }); + expect(response.status).toBe(200); + const ctx = lastCtx(); + expect(ctx?.http?.req).toBe(request); + // The Authorization header is visible on the raw request, but it is + // never promoted to validated auth info by the transport. + expect(ctx?.http?.req?.headers.get('authorization')).toBe('Bearer super-secret-token'); + expect(ctx?.http?.authInfo).toBeUndefined(); + }); + + it('surfaces caller-provided authInfo unchanged', async () => { + const { server, lastCtx } = modernServer(); + const transport = await connectedTransport(server); + const authInfo = { token: 'validated-token', clientId: 'client-1', scopes: ['mcp'] }; + const response = await transport.handleMessage(toolsCall(), { authInfo }); + expect(response.status).toBe(200); + expect(lastCtx()?.http?.authInfo).toEqual(authInfo); + }); +}); + +describe('teardown and the close chain', () => { + it('close is idempotent and fires onclose exactly once', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server); + let closes = 0; + const previous = transport.onclose; + transport.onclose = () => { + closes += 1; + previous?.(); + }; + await transport.close(); + await transport.close(); + expect(closes).toBe(1); + }); + + it('server.close() and transport.close() do not re-enter each other', async () => { + const first = modernServer(); + const firstTransport = await connectedTransport(first.server); + await first.server.close(); + await firstTransport.close(); + + const second = modernServer(); + const secondTransport = await connectedTransport(second.server); + await secondTransport.close(); + await second.server.close(); + }); + + it('closing mid-request rejects the pending response and aborts the handler', async () => { + let observedSignal: AbortSignal | undefined; + const { server } = modernServer({ + toolsCallHandler: ctx => { + observedSignal = ctx.mcpReq.signal; + return new Promise(() => { + // never resolves; the exchange is torn down externally + }); + } + }); + const transport = await connectedTransport(server); + const pending = transport.handleMessage(toolsCall()); + const expectation = expect(pending).rejects.toThrow(/Connection closed/); + await new Promise(resolve => setTimeout(resolve, 5)); + await transport.close(); + await expectation; + expect(observedSignal?.aborted).toBe(true); + }); + + it('an aborted request signal cancels the exchange', async () => { + let observedSignal: AbortSignal | undefined; + const { server } = modernServer({ + toolsCallHandler: ctx => { + observedSignal = ctx.mcpReq.signal; + return new Promise(() => { + // parked until the client goes away + }); + } + }); + const transport = await connectedTransport(server); + const abortController = new AbortController(); + const request = new Request('http://localhost/mcp', { method: 'POST', signal: abortController.signal }); + const pending = transport.handleMessage(toolsCall(), { request }); + const expectation = expect(pending).rejects.toThrow(/Connection closed/); + await new Promise(resolve => setTimeout(resolve, 5)); + abortController.abort(); + await expectation; + expect(observedSignal?.aborted).toBe(true); + }); + + it('drops writes after close without raising', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server); + await transport.close(); + await expect(transport.send({ jsonrpc: '2.0', id: 1, result: {} }, { relatedRequestId: 1 })).resolves.toBeUndefined(); + }); + + it('drops messages unrelated to the in-flight request', async () => { + const { server } = modernServer({ + toolsCallHandler: async () => ({ content: [{ type: 'text', text: 'done' }] }) + }); + const transport = await connectedTransport(server); + const pending = transport.handleMessage(toolsCall()); + // A session-wide notification with no related request has nowhere to + // go on a per-request exchange. + await transport.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' }); + const response = await pending; + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + }); +}); + +describe('custom-method requests', () => { + it('serves custom (extension) methods registered with explicit schemas', async () => { + const { server } = modernServer(); + server.setRequestHandler('app/echo', { params: z.looseObject({ value: z.string() }) }, async params => ({ + echoed: params.value + })); + const transport = await connectedTransport(server); + const response = await transport.handleMessage({ + jsonrpc: '2.0', + id: 4, + method: 'app/echo', + params: { value: 'hello', _meta: ENVELOPE } + } as JSONRPCRequest); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { echoed: string } }; + expect(body.result.echoed).toBe('hello'); + }); +}); From fba7f38130f08350e8fc1de2c1cd0a6bf40b761b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 15 Jun 2026 20:31:54 +0000 Subject: [PATCH 3/8] feat(server): add the internal per-request invoke seam invoke(server, message, ctx) serves one classified inbound message on a per-request server instance by connecting a fresh single-exchange transport, injecting the message through the normal transport callback, and capturing the response value, with no changes to protocol dispatch. Marking factory instances as modern-era stays the calling entry's job; tests use the package-internal negotiated-version hook as a stand-in. Also adds era-parity tests pinning that the same malformed input produces the same JSON-RPC error shape on the 2025-era transport and on the per-request path, modulo an enumerated list of era-mandated differences (HTTP status mapping, the per-request envelope). --- packages/server/src/server/invoke.ts | 65 +++++++ .../test/server/eraParityErrorShapes.test.ts | 161 ++++++++++++++++++ .../server/test/server/invokeSeam.test.ts | 139 +++++++++++++++ 3 files changed, 365 insertions(+) create mode 100644 packages/server/src/server/invoke.ts create mode 100644 packages/server/test/server/eraParityErrorShapes.test.ts create mode 100644 packages/server/test/server/invokeSeam.test.ts diff --git a/packages/server/src/server/invoke.ts b/packages/server/src/server/invoke.ts new file mode 100644 index 0000000000..38c88d53a6 --- /dev/null +++ b/packages/server/src/server/invoke.ts @@ -0,0 +1,65 @@ +/** + * The internal per-request invoke seam for modern-era HTTP serving. + * + * One classified inbound message is served by composing existing pieces, with + * no changes to the protocol dispatch layer: + * + * server instance (from the consumer's factory) + * → `connect(per-request transport)` + * → inject the classified message through the transport's message callback + * → capture the value (a single JSON body or an SSE stream) via the + * transport's send path. + * + * The seam is value-returning and independently testable: it resolves with the + * HTTP `Response` for the exchange. Marking factory instances as modern-era + * (and installing modern-only handlers) is the calling entry's responsibility + * and happens before this seam runs; the seam itself never writes era state. + */ +import type { AuthInfo, JSONRPCNotification, JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core'; + +import type { McpServer } from './mcp.js'; +import type { PerRequestResponseMode } from './perRequestTransport.js'; +import { PerRequestHTTPServerTransport } from './perRequestTransport.js'; +import type { Server } from './server.js'; + +/** Per-exchange context for {@linkcode invoke}. */ +export interface InvokeContext { + /** The edge classification of the message (computed once, at the entry boundary). */ + classification: MessageClassification; + /** The original HTTP request, when serving HTTP traffic. */ + request?: globalThis.Request; + /** + * Validated authentication information supplied by the caller. Strictly + * pass-through — never derived from request headers by this seam. + */ + authInfo?: AuthInfo; + /** Response shaping for the exchange; defaults to `auto` (lazy SSE upgrade). */ + responseMode?: PerRequestResponseMode; +} + +/** + * Serves one classified inbound message on the given server instance and + * returns the HTTP response for the exchange. + * + * The instance is connected to a fresh single-exchange transport, the message + * is injected through the normal transport message path, and whatever the + * dispatch layer produces (the handler result, a protocol-level rejection, or + * streamed related messages followed by the result) is captured as the + * returned `Response`. Exchange teardown rides the transport's close chain; + * dropping the per-request instance afterwards is the caller's choice. + */ +export async function invoke( + server: Server | McpServer, + message: JSONRPCRequest | JSONRPCNotification, + ctx: InvokeContext +): Promise { + const transport = new PerRequestHTTPServerTransport({ + classification: ctx.classification, + ...(ctx.responseMode !== undefined && { responseMode: ctx.responseMode }) + }); + await server.connect(transport); + return transport.handleMessage(message, { + ...(ctx.request !== undefined && { request: ctx.request }), + ...(ctx.authInfo !== undefined && { authInfo: ctx.authInfo }) + }); +} diff --git a/packages/server/test/server/eraParityErrorShapes.test.ts b/packages/server/test/server/eraParityErrorShapes.test.ts new file mode 100644 index 0000000000..bb50f2e4e3 --- /dev/null +++ b/packages/server/test/server/eraParityErrorShapes.test.ts @@ -0,0 +1,161 @@ +/** + * Era-parity error shapes: the same malformed input produces the same + * JSON-RPC error shape on the 2025-era (session-oriented streamable HTTP + * transport) and on the modern per-request path — modulo an explicitly + * enumerated table of era-mandated differences. Anything outside that table + * is a parity regression. + */ +import type { CallToolResult, JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + ProtocolError, + setNegotiatedProtocolVersion +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport.js'; +import { Server } from '../../src/server/server.js'; +import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp.js'; + +const MODERN_REVISION = '2026-07-28'; +const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION }; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'parity-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +/** + * Era-mandated differences between the two serving paths for the inputs + * exercised below. Everything else must be identical. + * + * - HTTP status: pre-handler rejections are status-mapped on the modern + * per-request path (e.g. method-not-found answers HTTP 404), while the + * 2025-era transport always carries dispatch errors in-band on HTTP 200. + * - The modern era requires the per-request `_meta` envelope on every + * request; the inputs below carry it on the modern leg only, where it is + * wire-level bookkeeping that never reaches handlers. + */ +const ERA_MANDATED_DIFFERENCES = ['http-status-mapping', 'per-request-envelope'] as const; + +interface LegError { + status: number; + error: { code: number; message: string; data?: unknown }; +} + +function buildServer(): Server { + const server = new Server({ name: 'parity', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/call', async (): Promise => ({ content: [{ type: 'text', text: 'ok' }] })); + server.setRequestHandler('app/fail', { params: z.looseObject({}) }, async () => { + throw new ProtocolError(-32_002, 'resource missing'); + }); + return server; +} + +async function legacyLeg(body: Record): Promise { + const server = buildServer(); + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + await server.connect(transport); + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(body) + }) + ); + const parsed = (await response.json()) as { error: LegError['error'] }; + await server.close(); + return { status: response.status, error: parsed.error }; +} + +async function modernLeg(body: Record): Promise { + const server = buildServer(); + setNegotiatedProtocolVersion(server, MODERN_REVISION); + const transport = new PerRequestHTTPServerTransport({ classification: MODERN }); + await server.connect(transport); + const enveloped = { + ...body, + params: { ...(body['params'] as Record | undefined), _meta: ENVELOPE } + }; + const response = await transport.handleMessage(enveloped as unknown as JSONRPCRequest); + const parsed = (await response.json()) as { error: LegError['error'] }; + await server.close(); + return { status: response.status, error: parsed.error }; +} + +describe('era-parity error shapes', () => { + it('enumerates the era-mandated differences it tolerates', () => { + expect(ERA_MANDATED_DIFFERENCES).toEqual(['http-status-mapping', 'per-request-envelope']); + }); + + it('an unknown method produces the same JSON-RPC error on both legs (status mapping is the enumerated difference)', async () => { + const input = { jsonrpc: '2.0', id: 11, method: 'definitely/unknown', params: {} }; + const legacy = await legacyLeg(input); + const modern = await modernLeg(input); + + expect(legacy.error.code).toBe(-32_601); + expect(modern.error.code).toBe(legacy.error.code); + expect(modern.error.message).toBe(legacy.error.message); + expect(modern.error.data).toEqual(legacy.error.data); + + // Enumerated difference: http-status-mapping. + expect(legacy.status).toBe(200); + expect(modern.status).toBe(404); + }); + + it('a handler-thrown protocol error produces the same in-band JSON-RPC error on both legs', async () => { + const input = { jsonrpc: '2.0', id: 12, method: 'app/fail', params: {} }; + const legacy = await legacyLeg(input); + const modern = await modernLeg(input); + + expect(legacy.status).toBe(200); + expect(modern.status).toBe(200); + expect(legacy.error).toMatchObject({ code: -32_002, message: 'resource missing' }); + expect(modern.error).toEqual(legacy.error); + }); + + it('a handler-level invalid-params rejection produces the same in-band error code on both legs', async () => { + const failingParams = new Server({ name: 'parity-params', version: '1.0.0' }, { capabilities: {} }); + // Same registration on both legs: a custom method with a params schema + // the input does not satisfy. + const register = (server: Server) => + server.setRequestHandler('app/strict', { params: z.object({ value: z.string() }) }, async params => ({ ok: params.value })); + register(failingParams); + + const legacyTransport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + await failingParams.connect(legacyTransport); + const legacyResponse = await legacyTransport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 13, method: 'app/strict', params: { value: 7 } }) + }) + ); + const legacyBody = (await legacyResponse.json()) as { error: { code: number } }; + await failingParams.close(); + + const modernServer = new Server({ name: 'parity-params', version: '1.0.0' }, { capabilities: {} }); + register(modernServer); + setNegotiatedProtocolVersion(modernServer, MODERN_REVISION); + const modernTransport = new PerRequestHTTPServerTransport({ classification: MODERN }); + await modernServer.connect(modernTransport); + const modernResponse = await modernTransport.handleMessage({ + jsonrpc: '2.0', + id: 13, + method: 'app/strict', + params: { value: 7, _meta: ENVELOPE } + } as JSONRPCRequest); + const modernBody = (await modernResponse.json()) as { error: { code: number } }; + await modernServer.close(); + + expect(legacyBody.error.code).toBe(-32_602); + expect(modernBody.error.code).toBe(legacyBody.error.code); + // Handler-level invalid params stays in-band on both legs. + expect(legacyResponse.status).toBe(200); + expect(modernResponse.status).toBe(200); + }); +}); diff --git a/packages/server/test/server/invokeSeam.test.ts b/packages/server/test/server/invokeSeam.test.ts new file mode 100644 index 0000000000..98cd86377e --- /dev/null +++ b/packages/server/test/server/invokeSeam.test.ts @@ -0,0 +1,139 @@ +/** + * The internal per-request invoke seam: one classified message in, one HTTP + * response out — value-returning and independently testable, with no HTTP + * server and no changes to protocol dispatch. + * + * The tests mark factory instances as modern-era through the package-internal + * negotiated-version hook, standing in for the HTTP entry that will own that + * write in production. + */ +import type { JSONRPCNotification, JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + setNegotiatedProtocolVersion +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { invoke } from '../../src/server/invoke.js'; +import { McpServer } from '../../src/server/mcp.js'; +import { Server } from '../../src/server/server.js'; + +const MODERN_REVISION = '2026-07-28'; +const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION }; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'invoke-seam-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +const toolsCall = (name: string, args: Record): JSONRPCRequest => + ({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name, arguments: args, _meta: ENVELOPE } + }) as JSONRPCRequest; + +function modernMcpServer(): McpServer { + const mcpServer = new McpServer({ name: 'invoke-seam-test', version: '1.0.0' }); + mcpServer.registerTool('greet', { inputSchema: z.object({ who: z.string() }) }, async ({ who }) => ({ + content: [{ type: 'text', text: `hello ${who}` }] + })); + // Stand-in for the HTTP entry, which marks factory instances as modern-era + // at binding time through the same package-internal hook. + setNegotiatedProtocolVersion(mcpServer.server, MODERN_REVISION); + return mcpServer; +} + +describe('invoke', () => { + it('serves a classified request on a high-level server instance and returns the response value', async () => { + const response = await invoke(modernMcpServer(), toolsCall('greet', { who: 'world' }), { classification: MODERN }); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('hello world'); + }); + + it('serves a classified request on a low-level server instance', async () => { + const server = new Server({ name: 'low-level', version: '1.0.0' }, { capabilities: {} }); + server.setRequestHandler('app/sum', { params: z.looseObject({ a: z.number(), b: z.number() }) }, async params => ({ + sum: params.a + params.b + })); + setNegotiatedProtocolVersion(server, MODERN_REVISION); + const response = await invoke( + server, + { jsonrpc: '2.0', id: 7, method: 'app/sum', params: { a: 2, b: 3, _meta: ENVELOPE } } as JSONRPCRequest, + { classification: MODERN } + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { id: number; result: { sum: number } }; + expect(body.id).toBe(7); + expect(body.result.sum).toBe(5); + }); + + it('answers an era-removed method with method-not-found and HTTP 404', async () => { + const response = await invoke( + modernMcpServer(), + { jsonrpc: '2.0', id: 2, method: 'ping', params: { _meta: ENVELOPE } } as JSONRPCRequest, + { classification: MODERN } + ); + expect(response.status).toBe(404); + const body = (await response.json()) as { error: { code: number } }; + expect(body.error.code).toBe(-32_601); + }); + + it('acknowledges classified notifications with 202 and no body', async () => { + const response = await invoke( + modernMcpServer(), + { jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 99 } } as JSONRPCNotification, + { classification: MODERN } + ); + expect(response.status).toBe(202); + expect(await response.text()).toBe(''); + }); + + it('protects unmarked instances: modern-classified traffic gets the protocol-version error', async () => { + const mcpServer = new McpServer({ name: 'unmarked', version: '1.0.0' }); + mcpServer.registerTool('greet', { inputSchema: z.object({ who: z.string() }) }, async ({ who }) => ({ + content: [{ type: 'text', text: `hello ${who}` }] + })); + mcpServer.server.onerror = () => { + // the era mismatch is also surfaced out of band; irrelevant here + }; + const response = await invoke(mcpServer, toolsCall('greet', { who: 'world' }), { classification: MODERN }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: number; data: { supported: string[] } } }; + expect(body.error.code).toBe(-32_004); + expect(Array.isArray(body.error.data.supported)).toBe(true); + }); + + it('passes the original request and caller-supplied auth info through to handler context', async () => { + const mcpServer = new McpServer({ name: 'ctx-check', version: '1.0.0' }); + let seenAuthClientId: string | undefined; + let seenAuthorizationHeader: string | null | undefined; + mcpServer.registerTool('whoami', { inputSchema: z.object({}) }, async (_args, ctx) => { + seenAuthClientId = ctx.http?.authInfo?.clientId; + seenAuthorizationHeader = ctx.http?.req?.headers.get('authorization'); + return { content: [{ type: 'text', text: 'ok' }] }; + }); + setNegotiatedProtocolVersion(mcpServer.server, MODERN_REVISION); + + const request = new Request('http://localhost/mcp', { + method: 'POST', + headers: { authorization: 'Bearer raw-header-token' } + }); + const response = await invoke(mcpServer, toolsCall('whoami', {}), { + classification: MODERN, + request, + authInfo: { token: 'verified-token', clientId: 'client-42', scopes: ['mcp'] } + }); + expect(response.status).toBe(200); + // Caller-supplied auth info arrives as-is; the raw header stays a raw + // header and is never promoted to auth info by the seam. + expect(seenAuthClientId).toBe('client-42'); + expect(seenAuthorizationHeader).toBe('Bearer raw-header-token'); + }); +}); From e93709d242b6321ec683485574ccd5af5d02fe77 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 02:16:06 +0000 Subject: [PATCH 4/8] fix(server): key per-request HTTP error statuses on origin, not error code Only errors produced inside the dispatch window (the validation ladder, the era registry gate, a missing handler) are answered with the mapped HTTP status; handler-produced errors stay in-band on HTTP 200 whatever their code. Forced-sse exchanges open their stream only after the pre-dispatch gates pass, so those rejections keep their mapped status in every response mode. Also stop burning the single-use flag when handleMessage is called on a not-yet-connected transport. --- .../server/src/server/perRequestTransport.ts | 69 +++++++++++++++---- .../test/server/perRequestTransport.test.ts | 28 ++++++++ 2 files changed, 82 insertions(+), 15 deletions(-) diff --git a/packages/server/src/server/perRequestTransport.ts b/packages/server/src/server/perRequestTransport.ts index 0d77d091c6..74eaaca51a 100644 --- a/packages/server/src/server/perRequestTransport.ts +++ b/packages/server/src/server/perRequestTransport.ts @@ -55,7 +55,10 @@ import { * - `auto` (default): answer with a single JSON body unless the handler emits * a related message before its result, in which case the response upgrades * to an SSE stream. - * - `sse`: open the SSE stream immediately. + * - `sse`: always answer handler output over an SSE stream. The stream opens + * once the request has passed the pre-dispatch validation gates, so ladder + * rejections keep their mapped HTTP status instead of being framed onto a + * 200 stream. * - `json`: never stream; related messages other than the terminal response * are dropped. */ @@ -114,6 +117,16 @@ export class PerRequestHTTPServerTransport implements Transport { private _used = false; private _closed = false; private _terminalDelivered = false; + /** + * `true` only while the inbound message is being delivered synchronously + * to the connected protocol layer. The pre-handler gates (the era + * registry gate, the edge→instance handoff check, the missing-handler + * rejection) answer inside this window; request handlers always run + * after it (the protocol layer defers them to a microtask). An error + * sent inside the window is therefore ladder-originated, and an error + * sent after it is handler-produced. + */ + private _dispatchWindowOpen = false; private _requestId?: RequestId; private _deferredResponse?: DeferredResponse; private _sse?: SseSink; @@ -145,13 +158,13 @@ export class PerRequestHTTPServerTransport implements Transport { if (this._used) { throw new Error('PerRequestHTTPServerTransport serves exactly one exchange; construct a new transport per request'); } - this._used = true; if (!this._started || this.onmessage === undefined) { throw new Error('PerRequestHTTPServerTransport is not connected: connect a server to this transport before handling a message'); } if (this._closed) { throw new Error('PerRequestHTTPServerTransport is closed'); } + this._used = true; const signal = extra?.request?.signal; if (signal?.aborted) { @@ -184,11 +197,22 @@ export class PerRequestHTTPServerTransport implements Transport { this._abortCleanup = () => signal.removeEventListener('abort', onAbort); } - if (this._responseMode === 'sse') { - this.upgradeToSse(); + this._dispatchWindowOpen = true; + try { + this.onmessage(message, messageExtra); + } finally { + this._dispatchWindowOpen = false; } - this.onmessage(message, messageExtra); + if (this._responseMode === 'sse' && !this._closed && !this._deferredResponse.settled) { + // Forced-SSE exchanges open their stream as soon as the + // request has passed the pre-dispatch gates: a ladder + // rejection settles inside the dispatch window with its + // mapped HTTP status, while handler output — including + // comment frames written before the first message — streams + // as before. + this.upgradeToSse(); + } return promise; } @@ -223,23 +247,38 @@ export class PerRequestHTTPServerTransport implements Transport { } this._terminalDelivered = true; - if (this._sse !== undefined) { + // The HTTP status is keyed on the error's origin, not on its bare + // code: only errors produced inside the dispatch window — the + // 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. + const ladderStatus = + this._dispatchWindowOpen && isJSONRPCErrorResponse(message) + ? LADDER_ERROR_HTTP_STATUS[(message as JSONRPCErrorResponse).error.code] + : undefined; + if (ladderStatus !== undefined && this._sse === undefined) { + this.settleResponse(Response.json(message, { status: ladderStatus, headers: { 'Content-Type': 'application/json' } })); + queueMicrotask(() => void this.close()); + return; + } + + if (this._sse !== undefined || this._responseMode === 'sse') { // Finalize the stream: serialize the terminal result onto it // after everything already enqueued, then close. + if (this._sse === undefined) { + this.upgradeToSse(); + } this.writeMessageFrame(message); this.finalizeStream(); return; } - // Single JSON body. Errors produced before a handler ran (the - // protocol-version mismatch, the era registry gate, a missing - // handler) carry the codes in the ladder status table and are - // answered with the mapped HTTP status; every other error — - // whatever its code — stays in-band on HTTP 200. - const status = isJSONRPCErrorResponse(message) - ? (LADDER_ERROR_HTTP_STATUS[(message as JSONRPCErrorResponse).error.code] ?? 200) - : 200; - this.settleResponse(Response.json(message, { status, headers: { 'Content-Type': 'application/json' } })); + // Single JSON body. + this.settleResponse(Response.json(message, { status: 200, headers: { 'Content-Type': 'application/json' } })); queueMicrotask(() => void this.close()); return; } diff --git a/packages/server/test/server/perRequestTransport.test.ts b/packages/server/test/server/perRequestTransport.test.ts index 5e07beffa7..800effcc61 100644 --- a/packages/server/test/server/perRequestTransport.test.ts +++ b/packages/server/test/server/perRequestTransport.test.ts @@ -172,6 +172,34 @@ describe('HTTP status mapping', () => { expect(errorOf(await response.json())).toMatchObject({ code: -32_002, message: 'resource missing' }); }); + it('keeps a handler-thrown method-not-found error in-band on HTTP 200 (the status table is origin-keyed)', async () => { + // A handler relaying a downstream -32601 (a proxy/relay tool is the + // realistic case) is a handler-produced error: it must not be + // re-mapped to HTTP 404 just because the ladder table maps that code + // for ladder-originated rejections. + const { server } = modernServer({ + toolsCallHandler: async () => { + throw new ProtocolError(-32_601, 'Method not found'); + } + }); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(errorOf(await response.json())).toMatchObject({ code: -32_601, message: 'Method not found' }); + }); + + it('keeps a handler-thrown unsupported-protocol-version error in-band on HTTP 200', async () => { + const { server } = modernServer({ + toolsCallHandler: async () => { + throw new ProtocolError(-32_004, 'Unsupported protocol version: 2099-01-01'); + } + }); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(errorOf(await response.json())?.code).toBe(-32_004); + }); + it('keeps handler-produced invalid-params errors in-band on HTTP 200 (never status-mapped)', async () => { const { server } = modernServer({ toolsCallHandler: async () => { From bc4439eaa413436c5717bed22318cc3892fffc6f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 02:21:20 +0000 Subject: [PATCH 5/8] test(server): strengthen per-request transport teardown and rejection coverage Pin the forced-sse pre-dispatch rejection status, assert connection-closed rejections as the typed error, cover the already-aborted request signal, and make the post-close drop and late comment-frame tests fail if the closed guards are removed (they now assert nothing is reported through onerror). --- .../test/server/perRequestStreaming.test.ts | 45 ++++++++++++++++--- .../test/server/perRequestTransport.test.ts | 31 +++++++++++-- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/packages/server/test/server/perRequestStreaming.test.ts b/packages/server/test/server/perRequestStreaming.test.ts index ea2ec61d57..ba56b6e543 100644 --- a/packages/server/test/server/perRequestStreaming.test.ts +++ b/packages/server/test/server/perRequestStreaming.test.ts @@ -129,11 +129,21 @@ describe('lazy upgrade matrix', () => { }); it('drops writes after the exchange is closed', async () => { - const { transport } = await setup(async () => ({ content: [] })); + // A streamed exchange whose stream has already been finalized: a late + // related write must be dropped by the closed-guard. If that guard + // were removed, the write would hit the closed stream controller and + // be reported through onerror. + const { transport } = await setup(async ctx => { + await ctx.mcpReq.notify(progressNotification(1)); + return { content: [] }; + }); const response = await transport.handleMessage(toolsCall()); - expect(response.status).toBe(200); + await response.text(); await transport.close(); + const errors: Error[] = []; + transport.onerror = error => errors.push(error); await expect(transport.send(progressNotification(9) as never, { relatedRequestId: 1 })).resolves.toBeUndefined(); + expect(errors).toHaveLength(0); }); }); @@ -147,6 +157,25 @@ describe('forced response modes (the seam the entry-level knob plugs into)', () expect(dataOf(frames[0]!)).toMatchObject({ result: { content: [{ type: 'text', text: 'eager' }] } }); }); + it('sse mode still answers pre-dispatch rejections with their mapped HTTP status', async () => { + // The forced-sse stream opens only after the pre-dispatch gates pass: + // a request the validation ladder rejects (here: an unknown method + // with no handler) keeps the spec-mandated HTTP status instead of + // being framed onto a 200 stream. + const { transport } = await setup(async () => ({ content: [] }), 'sse'); + const unknownMethod = { + jsonrpc: '2.0', + id: 1, + method: 'definitely/unknown', + params: { _meta: ENVELOPE } + } as JSONRPCRequest; + const response = await transport.handleMessage(unknownMethod); + expect(response.status).toBe(404); + expect(response.headers.get('content-type')).toContain('application/json'); + const body = (await response.json()) as { error?: { code: number } }; + expect(body.error?.code).toBe(-32_601); + }); + it('json mode never upgrades and drops mid-call notifications', async () => { const { transport } = await setup(async ctx => { await ctx.mcpReq.notify(progressNotification(1)); @@ -175,8 +204,9 @@ describe('comment frames', () => { }, 'sse'); const responsePromise = transport.handleMessage(toolsCall()); - // The stream is open (sse mode settles immediately); a comment frame - // written now must be delivered to the consumer. + // The stream is open (sse mode settles once the pre-dispatch gates + // pass); a comment frame written now must be delivered to the + // consumer. transport.writeCommentFrame('keep-alive'); release(); const response = await responsePromise; @@ -184,9 +214,12 @@ describe('comment frames', () => { expect(text).toContain(': keep-alive'); // After the exchange completed (and the transport closed itself), - // comment frames are dropped silently. + // comment frames are dropped silently — and never surface as stream + // write errors, which is what would happen without the closed-guard. + const errors: Error[] = []; + transport.onerror = error => errors.push(error); transport.writeCommentFrame('late'); - expect(text).not.toContain(': late'); + expect(errors).toHaveLength(0); }); }); diff --git a/packages/server/test/server/perRequestTransport.test.ts b/packages/server/test/server/perRequestTransport.test.ts index 800effcc61..f61fd3cd00 100644 --- a/packages/server/test/server/perRequestTransport.test.ts +++ b/packages/server/test/server/perRequestTransport.test.ts @@ -10,6 +10,8 @@ import { CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY, ProtocolError, + SdkError, + SdkErrorCode, setNegotiatedProtocolVersion } from '@modelcontextprotocol/core'; import { describe, expect, it } from 'vitest'; @@ -291,7 +293,9 @@ describe('teardown and the close chain', () => { }); const transport = await connectedTransport(server); const pending = transport.handleMessage(toolsCall()); - const expectation = expect(pending).rejects.toThrow(/Connection closed/); + const expectation = expect(pending).rejects.toSatisfy( + (error: unknown) => error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed + ); await new Promise(resolve => setTimeout(resolve, 5)); await transport.close(); await expectation; @@ -312,18 +316,39 @@ describe('teardown and the close chain', () => { const abortController = new AbortController(); const request = new Request('http://localhost/mcp', { method: 'POST', signal: abortController.signal }); const pending = transport.handleMessage(toolsCall(), { request }); - const expectation = expect(pending).rejects.toThrow(/Connection closed/); + const expectation = expect(pending).rejects.toSatisfy( + (error: unknown) => error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed + ); await new Promise(resolve => setTimeout(resolve, 5)); abortController.abort(); await expectation; expect(observedSignal?.aborted).toBe(true); }); - it('drops writes after close without raising', async () => { + it('rejects with the typed connection-closed error when the request signal is already aborted', async () => { + const { server, lastCtx } = modernServer(); + const transport = await connectedTransport(server); + const abortController = new AbortController(); + abortController.abort(); + const request = new Request('http://localhost/mcp', { method: 'POST', signal: abortController.signal }); + await expect(transport.handleMessage(toolsCall(), { request })).rejects.toSatisfy( + (error: unknown) => error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed + ); + // The handler never ran; the exchange was torn down before dispatch. + expect(lastCtx()).toBeUndefined(); + }); + + it('drops writes after close without raising or reporting through onerror', async () => { const { server } = modernServer(); const transport = await connectedTransport(server); await transport.close(); + // If the closed-guard were removed, this response (for a request the + // transport never saw) would be reported through onerror as an + // unknown-request-id write. + const errors: Error[] = []; + transport.onerror = error => errors.push(error); await expect(transport.send({ jsonrpc: '2.0', id: 1, result: {} }, { relatedRequestId: 1 })).resolves.toBeUndefined(); + expect(errors).toHaveLength(0); }); it('drops messages unrelated to the in-flight request', async () => { From ac6d58a5fa0dbd29dfdee6e961a96ab475a93aa4 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 02:42:48 +0000 Subject: [PATCH 6/8] fix(core): reject malformed notification claims and stop fabricating `requested` - A notification whose _meta carries the protocol-version key with a non-string value is now rejected with the same invalid-params outcome as a malformed request claim, naming the offending key, instead of classifying modern with no revision and silently winning against a disagreeing header. Notifications without a claim keep header-determinative routing unchanged. - The modern-only strict rejection no longer fabricates requested: when the request named no protocol version: the field is only present when the request actually named one (initialize body or protocol-version header). - Cell sheet: add the empty-batch, notification envelope, and notification header/body mismatch rows; record the deliberate -32600 vs -32700 divergence from the deployed 2025-era transport in the invalid-body rationales. --- .../core/src/shared/inboundClassification.ts | 61 ++++++++++++++----- .../test/shared/inboundClassification.test.ts | 44 ++++++++++--- .../shared/inboundLadderCellSheet.test.ts | 52 +++++++++++++++- 3 files changed, 134 insertions(+), 23 deletions(-) diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index 28433bcd77..4b14f18ee0 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -20,6 +20,9 @@ * spec, so for notification POSTs without a body claim the modern header is * determinative; the `Mcp-Method` header is validated against the body when * the message classifies modern and is never enforced on legacy traffic. + * A notification that does carry a claim is treated body-primary like a + * request, and a malformed claim is rejected the same way a request's + * malformed claim is — never silently resolved against the header. * - `GET`/`DELETE` (and any other non-`POST` method) are body-less 2025-era * session operations: the modern era is `POST`-only, so they are routed to * legacy serving when it is configured and rejected otherwise. @@ -41,7 +44,7 @@ * `settled: false` so tests and consumers can treat them as parameterized * rather than pinned. */ -import { LATEST_PROTOCOL_VERSION } from '../types/constants.js'; +import { PROTOCOL_VERSION_META_KEY } from '../types/constants.js'; import { ProtocolErrorCode } from '../types/enums.js'; import { ProtocolError, UnsupportedProtocolVersionError } from '../types/errors.js'; import { isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse } from '../types/guards.js'; @@ -478,7 +481,30 @@ function classifyNotificationBody(request: InboundHttpRequest, body: Record issue.key === PROTOCOL_VERSION_META_KEY) ?? { + key: PROTOCOL_VERSION_META_KEY, + problem: 'expected a protocol version string' + }; + return rejection( + 'envelope', + 'notification-envelope-invalid', + 400, + new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Invalid _meta envelope for protocol revision 2026-07-28: ${claimIssue.key}: ${claimIssue.problem}`, + { envelope: claimIssue } + ), + true + ); + } + if (headerVersion !== undefined && headerVersion !== claimedVersion) { return crossCheckMismatch( 'notification-header-body-version-mismatch', headerVersion, @@ -568,10 +594,11 @@ export function classifyInboundRequest(request: InboundHttpRequest): InboundClas * * - Envelope-less requests (including `initialize`) are answered with the * unsupported-protocol-version error carrying the endpoint's supported - * versions and echoing the version the request named, so a legacy client - * can discover what the endpoint serves from the error alone. (This cell - * shares its numeric code with the still-disputed mismatch cells above, - * but its own outcome is settled.) + * versions and echoing the version the request named (when it named one — + * `requested` is omitted rather than fabricated when the request named no + * version at all), so a legacy client can discover what the endpoint serves + * from the error alone. (This cell shares its numeric code with the + * still-disputed mismatch cells above, but its own outcome is settled.) * - Posted responses and batch arrays are invalid requests on the modern era. * - Non-`POST` methods are not allowed. * - Legacy-classified notifications return `undefined`: the caller answers @@ -608,14 +635,20 @@ export function modernOnlyStrictRejection( } case 'initialize': case 'no-claim': { - const requested = route.requestedVersion ?? LATEST_PROTOCOL_VERSION; - return rejection( - 'era-classification', - 'modern-only-missing-envelope', - 400, - new UnsupportedProtocolVersionError({ supported: [...supportedVersions], requested }), - true - ); + // `requested` reflects what the request actually named (an + // initialize body's `protocolVersion` or the protocol-version + // header); when the request named no version at all the field is + // omitted rather than fabricated. + const requested = route.requestedVersion; + const error = + requested === undefined + ? new ProtocolError( + ProtocolErrorCode.UnsupportedProtocolVersion, + 'Unsupported protocol version: the request did not name a protocol version', + { supported: [...supportedVersions] } + ) + : new UnsupportedProtocolVersionError({ supported: [...supportedVersions], requested }); + return rejection('era-classification', 'modern-only-missing-envelope', 400, error, true); } } } diff --git a/packages/core/test/shared/inboundClassification.test.ts b/packages/core/test/shared/inboundClassification.test.ts index 523a7dd698..9c5dd8ec2f 100644 --- a/packages/core/test/shared/inboundClassification.test.ts +++ b/packages/core/test/shared/inboundClassification.test.ts @@ -20,12 +20,7 @@ import { modernOnlyStrictRejection, PROVISIONAL_CROSS_CHECK_MISMATCH_CODE } from '../../src/shared/inboundClassification.js'; -import { - CLIENT_CAPABILITIES_META_KEY, - CLIENT_INFO_META_KEY, - LATEST_PROTOCOL_VERSION, - PROTOCOL_VERSION_META_KEY -} from '../../src/types/constants.js'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../src/types/constants.js'; const MODERN_REVISION = '2026-07-28'; const MISMATCH_CODE_CANDIDATES = [-32_001, -32_602, -32_004]; @@ -265,6 +260,37 @@ describe('notification routing (header determinative when the body carries no cl const conflicting = classifyInboundRequest(post(notification('notifications/progress', meta), { protocolVersion: '2025-06-18' })); expectMismatch(conflicting, 'notification-header-body-version-mismatch'); }); + + test('a notification claim with a malformed value is rejected, naming the offending key', () => { + // Validated exactly like a request claim: invalid params naming the + // key — never silently losing to (or overriding) a disagreeing header. + const meta = { [PROTOCOL_VERSION_META_KEY]: 42 }; + const outcome = classifyInboundRequest(post(notification('notifications/progress', meta))); + expect(outcome).toMatchObject({ + kind: 'reject', + rung: 'envelope', + cell: 'notification-envelope-invalid', + httpStatus: 400, + code: -32_602, + settled: true + }); + if (outcome.kind !== 'reject') return; + const data = outcome.data as { envelope: { key: string } }; + expect(data.envelope.key).toBe(PROTOCOL_VERSION_META_KEY); + }); + + test('a notification claim with a malformed value is rejected the same way when a legacy header disagrees', () => { + const meta = { [PROTOCOL_VERSION_META_KEY]: 42 }; + const outcome = classifyInboundRequest(post(notification('notifications/progress', meta), { protocolVersion: '2025-06-18' })); + expect(outcome).toMatchObject({ kind: 'reject', rung: 'envelope', cell: 'notification-envelope-invalid', code: -32_602 }); + }); + + test('a notification with no claim at all keeps header-determinative routing (not envelope-validated)', () => { + // Only a present claim is validated; claim-less notifications keep the + // header-determinative routing above unchanged. + expect(classifyInboundRequest(post(notification(), { protocolVersion: MODERN_REVISION }))).toMatchObject({ kind: 'modern' }); + expect(classifyInboundRequest(post(notification()))).toMatchObject({ kind: 'legacy', reason: 'notification' }); + }); }); describe('element-wise batch classification', () => { @@ -329,15 +355,17 @@ describe('modern-only (strict) rejection mapping', () => { return outcome as InboundLegacyRoute; }; - test('an envelope-less request answers the unsupported-protocol-version error with the supported list', () => { + test('an envelope-less request that named no version omits `requested` rather than fabricating one', () => { const rejectionOutcome = modernOnlyStrictRejection(legacyRoute(legacyToolsList()), SUPPORTED); expect(rejectionOutcome).toMatchObject({ cell: 'modern-only-missing-envelope', httpStatus: 400, code: -32_004, settled: true, - data: { supported: SUPPORTED, requested: LATEST_PROTOCOL_VERSION } + data: { supported: SUPPORTED } }); + expect((rejectionOutcome?.data as { requested?: unknown })?.requested).toBeUndefined(); + expect(Object.keys(rejectionOutcome?.data as Record)).not.toContain('requested'); expect(rejectionOutcome?.message).toContain('Unsupported protocol version'); }); diff --git a/packages/core/test/shared/inboundLadderCellSheet.test.ts b/packages/core/test/shared/inboundLadderCellSheet.test.ts index 28ddfa7375..3c66e933db 100644 --- a/packages/core/test/shared/inboundLadderCellSheet.test.ts +++ b/packages/core/test/shared/inboundLadderCellSheet.test.ts @@ -177,7 +177,31 @@ const SHEET: readonly SheetRow[] = [ conformance: [], input: post({ hello: 'world' }), reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, - rationale: 'A POST body that is not a JSON-RPC message is an invalid request.' + rationale: + 'A POST body that is not a JSON-RPC message is an invalid request (-32600, the JSON-RPC-correct code). Deliberate ' + + 'divergence from the deployed 2025-era transport, which answers -32700 for the same parsed body; enumerated and ' + + 'exercised on both legs in the era-parity suite (server package).' + }, + { + cell: 'empty-batch', + status: 'pinned', + conformance: [], + input: post([]), + reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, + rationale: + 'An empty JSON-RPC batch is an invalid request at the modern edge. Deliberate divergence from the deployed 2025-era ' + + 'transport, which accepts an empty array as containing only notifications (202, no body); enumerated and exercised on ' + + 'both legs in the era-parity suite (server package).' + }, + { + cell: 'notification-envelope-invalid', + status: 'pinned', + conformance: [], + input: post({ jsonrpc: '2.0', method: 'notifications/progress', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: 42 } } }), + reject: { rung: 'envelope', httpStatus: 400, code: -32_602, settled: true }, + rationale: + 'A notification claim with a malformed protocol-version value is invalid params naming the key — exactly like the ' + + 'request path, never a silent win against (or loss to) a disagreeing header.' }, /* --- Modern-only (strict) cells (pinned) --------------------------------------- */ @@ -267,6 +291,32 @@ const SHEET: readonly SheetRow[] = [ reject: { rung: 'era-classification', httpStatus: 400, settled: false }, rationale: 'The Mcp-Method header must describe the body it accompanies; the rejection code is pending upstream settlement.' }, + { + cell: 'notification-header-body-version-mismatch', + status: 'parameterized', + conformance: [], + input: post( + { jsonrpc: '2.0', method: 'notifications/progress', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION } } }, + { protocolVersion: '2025-06-18' } + ), + reject: { rung: 'era-classification', httpStatus: 400, settled: false }, + rationale: + 'A notification body claim disagreeing with the protocol-version header is the same disagreement family as the request ' + + 'cells above; the exact code is still under discussion upstream.' + }, + { + cell: 'notification-method-header-mismatch', + status: 'parameterized', + conformance: [], + input: post( + { jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: 1, progress: 1 } }, + { protocolVersion: MODERN_REVISION, mcpMethod: 'notifications/cancelled' } + ), + reject: { rung: 'era-classification', httpStatus: 400, settled: false }, + rationale: + 'The Mcp-Method header must describe the notification body it accompanies (validated only when the notification ' + + 'classifies modern); the rejection code is pending upstream settlement.' + }, { cell: 'multi-fault-mismatched-claim-and-malformed-envelope', status: 'parameterized', From 4b66ee947501d63dc4e1089d1bdb3a0bff44c8db Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 02:42:48 +0000 Subject: [PATCH 7/8] test(server): assert era-parity divergences literally on both serving paths - The era-parity suite's difference enumeration was previously only compared to itself. It now hand-enumerates the known divergences between the deployed 2025-era transport and the modern edge - a parsed-but-not-JSON-RPC single object (legacy answers -32700, the modern edge answers -32600, both HTTP 400) and an empty batch (legacy answers 202, the modern edge answers -32600/400) - and asserts both actual behaviors against those literals so a behavior change on either side fails the test. - Correct the invoke() JSDoc: only request exchanges run the close chain after the terminal response is delivered; notification exchanges resolve with the 202 response without tearing the transport down. --- packages/server/src/server/invoke.ts | 7 +- .../test/server/eraParityErrorShapes.test.ts | 93 ++++++++++++++++++- 2 files changed, 94 insertions(+), 6 deletions(-) diff --git a/packages/server/src/server/invoke.ts b/packages/server/src/server/invoke.ts index 38c88d53a6..f6c9c11359 100644 --- a/packages/server/src/server/invoke.ts +++ b/packages/server/src/server/invoke.ts @@ -45,8 +45,11 @@ export interface InvokeContext { * is injected through the normal transport message path, and whatever the * dispatch layer produces (the handler result, a protocol-level rejection, or * streamed related messages followed by the result) is captured as the - * returned `Response`. Exchange teardown rides the transport's close chain; - * dropping the per-request instance afterwards is the caller's choice. + * returned `Response`. For request exchanges, teardown rides the transport's + * close chain once the terminal response has been delivered; notification + * exchanges resolve with the 202 response immediately and do NOT run the + * close chain — the transport stays connected until the caller closes it or + * drops the per-request instance, which is the caller's choice either way. */ export async function invoke( server: Server | McpServer, diff --git a/packages/server/test/server/eraParityErrorShapes.test.ts b/packages/server/test/server/eraParityErrorShapes.test.ts index bb50f2e4e3..7e80616a0b 100644 --- a/packages/server/test/server/eraParityErrorShapes.test.ts +++ b/packages/server/test/server/eraParityErrorShapes.test.ts @@ -7,6 +7,7 @@ */ import type { CallToolResult, JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core'; import { + classifyInboundRequest, CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY, @@ -36,11 +37,53 @@ const ENVELOPE = { * - HTTP status: pre-handler rejections are status-mapped on the modern * per-request path (e.g. method-not-found answers HTTP 404), while the * 2025-era transport always carries dispatch errors in-band on HTTP 200. + * Asserted literally on both legs by the unknown-method test below. * - The modern era requires the per-request `_meta` envelope on every * request; the inputs below carry it on the modern leg only, where it is * wire-level bookkeeping that never reaches handlers. + * - The malformed-body divergences enumerated in {@link KNOWN_EDGE_DIVERGENCES}, + * asserted literally on both legs by the divergence-table test below. */ -const ERA_MANDATED_DIFFERENCES = ['http-status-mapping', 'per-request-envelope'] as const; + +/** + * Known, deliberate divergences between what the deployed 2025-era streamable + * HTTP transport answers for a malformed POST body and what the modern edge + * (the inbound classifier) answers for the same body. + * + * These are hand-written literals — NOT derived from the observed behavior of + * either leg — so a behavior change on EITHER side fails the assertions below + * and forces this enumeration (and the matching cell-sheet rationales in the + * core package) to be revisited. + */ +const KNOWN_EDGE_DIVERGENCES: ReadonlyArray<{ + divergence: string; + /** The parsed POST body both legs receive. */ + body: unknown; + /** What the deployed 2025-era transport answers today. */ + legacy: { httpStatus: number; code?: number }; + /** What the modern edge (the inbound classifier) answers. */ + modernEdge: { httpStatus: number; code: number }; + rationale: string; +}> = [ + { + divergence: 'parsed-but-not-json-rpc-single-object', + body: { hello: 'world' }, + legacy: { httpStatus: 400, code: -32_700 }, + modernEdge: { httpStatus: 400, code: -32_600 }, + rationale: + 'The deployed transport answers a parse error (-32700) for a parsed body that is not a JSON-RPC message; the modern ' + + 'edge answers the JSON-RPC-correct invalid request (-32600).' + }, + { + divergence: 'empty-batch', + body: [], + legacy: { httpStatus: 202 }, + modernEdge: { httpStatus: 400, code: -32_600 }, + rationale: + 'The deployed transport accepts an empty batch as containing only notifications (202, no body); the modern edge ' + + 'rejects it as an invalid request.' + } +]; interface LegError { status: number; @@ -56,6 +99,30 @@ function buildServer(): Server { return server; } +/** + * Posts an arbitrary (possibly malformed) body to the deployed 2025-era + * transport and returns the raw HTTP outcome — unlike {@link legacyLeg}, it + * does not assume the response carries a JSON error body (a 202 has none). + */ +async function legacyRawLeg(body: unknown): Promise<{ status: number; error?: LegError['error'] }> { + const server = buildServer(); + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + await server.connect(transport); + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(body) + }) + ); + const text = await response.text(); + await server.close(); + return { + status: response.status, + ...(text.length > 0 && { error: (JSON.parse(text) as { error: LegError['error'] }).error }) + }; +} + async function legacyLeg(body: Record): Promise { const server = buildServer(); const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); @@ -88,9 +155,27 @@ async function modernLeg(body: Record): Promise { } describe('era-parity error shapes', () => { - it('enumerates the era-mandated differences it tolerates', () => { - expect(ERA_MANDATED_DIFFERENCES).toEqual(['http-status-mapping', 'per-request-envelope']); - }); + it.each(KNOWN_EDGE_DIVERGENCES)( + 'known divergence "$divergence": both legs answer exactly what the table enumerates', + async ({ body, legacy, modernEdge }) => { + // Legacy leg: the deployed 2025-era transport, exercised over HTTP. + const legacyActual = await legacyRawLeg(body); + expect(legacyActual.status).toBe(legacy.httpStatus); + if (legacy.code !== undefined) { + expect(legacyActual.error?.code).toBe(legacy.code); + } else { + expect(legacyActual.error).toBeUndefined(); + } + + // Modern leg: the per-request path answers these bodies at the + // edge (the inbound classifier) — they never reach a transport. + const modernActual = classifyInboundRequest({ httpMethod: 'POST', body }); + expect(modernActual.kind).toBe('reject'); + if (modernActual.kind !== 'reject') return; + expect(modernActual.httpStatus).toBe(modernEdge.httpStatus); + expect(modernActual.code).toBe(modernEdge.code); + } + ); it('an unknown method produces the same JSON-RPC error on both legs (status mapping is the enumerated difference)', async () => { const input = { jsonrpc: '2.0', id: 11, method: 'definitely/unknown', params: {} }; From ae9172355a0d8f7ade21dddc233fdaf670e178f2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 13:08:50 +0000 Subject: [PATCH 8/8] fix(core): classify an enveloped initialize as a modern request The inbound classifier evaluated the initialize => legacy-handshake rule before envelope-claim detection, so a request carrying a fully valid 2026-07-28 envelope whose method happened to be initialize was routed to legacy serving (or rejected as a header mismatch) instead of being served on the modern path, where the era registry answers it with method-not-found (-32601, HTTP 404) like every other method the revision does not define. A valid envelope claim naming a modern revision now takes precedence over the method-name rule. Everything else is unchanged: envelope-less initialize requests keep the legacy-handshake classification (including the unsupported-protocol-version rejection naming the supported versions on modern-only endpoints), malformed claims and claims naming pre-2026 revisions keep today's routing, and the header/body cross-checks still apply to enveloped initialize requests. --- .../core/src/shared/inboundClassification.ts | 41 ++++++++++++++++-- .../test/shared/inboundClassification.test.ts | 42 +++++++++++++++++++ .../shared/inboundLadderCellSheet.test.ts | 25 +++++++++-- 3 files changed, 101 insertions(+), 7 deletions(-) diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index 4b14f18ee0..f731796280 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -7,7 +7,11 @@ * exactly once, at the entry boundary, on the already-parsed request body: * * - `initialize` is a legacy-era request by definition (the modern era has no - * `initialize` handshake). + * `initialize` handshake) — unless it carries a valid envelope claim naming + * a modern revision, in which case the claim wins and the request is + * classified like any other enveloped request (the modern era then answers + * it with method-not-found, exactly like every other method it does not + * define). * - A request whose `params._meta` carries the reserved protocol-version key * claims the per-request envelope mechanism and classifies into the era the * named revision belongs to (a malformed envelope behind a present claim is @@ -81,7 +85,7 @@ export interface InboundHttpRequest { export type InboundLegacyRouteReason = /** Non-`POST` HTTP method: a body-less 2025-era session operation. */ | 'http-method' - /** An `initialize` request — the legacy handshake by definition. */ + /** An `initialize` request without a valid modern envelope claim — the legacy handshake by definition. */ | 'initialize' /** A request without a per-request envelope claim. */ | 'no-claim' @@ -353,6 +357,29 @@ function classificationForClaim(claimedVersion: string | undefined): MessageClas return { era: isModernProtocolVersion(claimedVersion) ? 'modern' : 'legacy', revision: claimedVersion }; } +/** + * Whether a request's params carry a per-request envelope claim that is both + * well-formed and names a modern protocol revision. + * + * Used by the `initialize` precedence rule: only such a claim overrides the + * `initialize` ⇒ legacy-handshake classification — a request carrying a valid + * modern envelope is a modern request regardless of its method name, and the + * modern era then answers `initialize` exactly like any other method it does + * not define (method-not-found). A malformed claim, or one naming a pre-2026 + * revision, keeps the legacy-handshake routing unchanged. + */ +function carriesValidModernEnvelopeClaim(params: unknown): boolean { + if (!hasEnvelopeClaim(params)) { + return false; + } + const claimedVersion = envelopeClaimVersion(params); + if (claimedVersion === undefined || !isModernProtocolVersion(claimedVersion)) { + return false; + } + const meta = requestMetaOf(params); + return meta !== undefined && validateEnvelopeMeta(meta).length === 0; +} + function classifyBatch(body: readonly unknown[]): InboundClassificationOutcome { if (body.length === 0) { return rejection( @@ -405,8 +432,14 @@ function classifyRequestBody(request: InboundHttpRequest, body: Record { expect(outcome).toMatchObject({ kind: 'legacy', reason: 'initialize', requestedVersion: '2025-03-26' }); }); + test('an initialize carrying a valid modern envelope claim classifies modern (the claim wins over the handshake rule)', () => { + // Body-primary: no headers at all, the valid claim alone decides. The + // modern path then answers `initialize` as method-not-found, exactly + // like every other method the modern revision does not define. + const body = { jsonrpc: '2.0', id: 7, method: 'initialize', params: { _meta: ENVELOPE } }; + expect(classifyInboundRequest(post(body))).toMatchObject({ + kind: 'modern', + messageKind: 'request', + classification: { era: 'modern', revision: MODERN_REVISION } + }); + + // The same request with conformant standard headers (the wire shape a + // modern client actually sends) classifies the same way. + const withHeaders = classifyInboundRequest(post(body, { protocolVersion: MODERN_REVISION, mcpMethod: 'initialize' })); + expect(withHeaders).toMatchObject({ kind: 'modern', classification: { era: 'modern', revision: MODERN_REVISION } }); + }); + + test('an initialize with a malformed envelope claim keeps the legacy-handshake classification', () => { + const body = { + jsonrpc: '2.0', + id: 7, + method: 'initialize', + params: { protocolVersion: '2025-06-18', _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION } } + }; + expect(classifyInboundRequest(post(body))).toMatchObject({ kind: 'legacy', reason: 'initialize', requestedVersion: '2025-06-18' }); + }); + + test('an initialize whose valid envelope claim names a pre-2026 revision keeps the legacy-handshake classification', () => { + const meta = { ...ENVELOPE, [PROTOCOL_VERSION_META_KEY]: '2025-06-18' }; + const body = { jsonrpc: '2.0', id: 7, method: 'initialize', params: { _meta: meta } }; + expect(classifyInboundRequest(post(body))).toMatchObject({ kind: 'legacy', reason: 'initialize' }); + }); + test('GET and DELETE are method-routed legacy session operations', () => { expect(classifyInboundRequest({ httpMethod: 'GET' })).toMatchObject({ kind: 'legacy', reason: 'http-method' }); expect(classifyInboundRequest({ httpMethod: 'DELETE' })).toMatchObject({ kind: 'legacy', reason: 'http-method' }); @@ -202,6 +235,15 @@ describe('header cross-checks (parameterized mismatch family)', () => { expectMismatch(outcome, 'initialize-with-modern-header'); }); + test('an enveloped initialize whose claim disagrees with the protocol-version header is still a mismatch outcome', () => { + // The claim precedence never bypasses the cross-checks: an initialize + // carrying a valid modern claim is checked against the header exactly + // like any other enveloped request. + const body = { jsonrpc: '2.0', id: 7, method: 'initialize', params: { _meta: ENVELOPE } }; + const outcome = classifyInboundRequest(post(body, { protocolVersion: '2025-06-18' })); + expectMismatch(outcome, 'header-body-version-mismatch'); + }); + test('an Mcp-Method header disagreeing with the body method is a mismatch outcome on modern requests', () => { const outcome = classifyInboundRequest(post(modernToolsCall(), { protocolVersion: MODERN_REVISION, mcpMethod: 'tools/list' })); expectMismatch(outcome, 'method-header-mismatch'); diff --git a/packages/core/test/shared/inboundLadderCellSheet.test.ts b/packages/core/test/shared/inboundLadderCellSheet.test.ts index 3c66e933db..d1e661543b 100644 --- a/packages/core/test/shared/inboundLadderCellSheet.test.ts +++ b/packages/core/test/shared/inboundLadderCellSheet.test.ts @@ -104,6 +104,17 @@ const SHEET: readonly SheetRow[] = [ route: 'legacy', rationale: 'initialize is the legacy handshake by definition; the modern era has no initialize.' }, + { + cell: 'modern-enveloped-initialize', + status: 'pinned', + conformance: ['server-stateless'], + input: post(enveloped('initialize'), { protocolVersion: MODERN_REVISION, mcpMethod: 'initialize' }), + route: 'modern', + rationale: + 'A valid modern envelope claim wins over the initialize ⇒ legacy-handshake rule: the request is served on the modern path, ' + + 'where the modern registry answers initialize as method-not-found (-32601, HTTP 404 via the ladder status table) like every ' + + 'other method the revision does not define.' + }, { cell: 'legacy-method-routed-get', status: 'pinned', @@ -222,8 +233,16 @@ const SHEET: readonly SheetRow[] = [ conformance: ['server-stateless'], input: post(bare('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '1' } })), strict: true, - reject: { rung: 'era-classification', httpStatus: 400, code: -32_004, settled: true }, - rationale: 'An envelope-less initialize on a modern-only endpoint is answered with the version error naming both sides.' + reject: { + rung: 'era-classification', + httpStatus: 400, + code: -32_004, + settled: true, + data: { supported: [MODERN_REVISION], requested: '2025-06-18' } + }, + rationale: + 'An envelope-less initialize on a modern-only endpoint is answered with the version error naming both sides — the ' + + 'unsupported-protocol-version rejection with the supported list stays reserved for envelope-less requests.' }, { cell: 'modern-only-method-not-allowed', @@ -278,7 +297,7 @@ const SHEET: readonly SheetRow[] = [ protocolVersion: MODERN_REVISION }), reject: { rung: 'era-classification', httpStatus: 400, settled: false }, - rationale: 'initialize classifies legacy; a modern header on it is the same disagreement family.' + rationale: 'An envelope-less initialize classifies legacy; a modern header on it is the same disagreement family.' }, { cell: 'method-header-mismatch',