From d0b9861034f653097e7a280be952ff32405de433 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 04:15:55 +0000 Subject: [PATCH 01/14] feat(core): add the typed missing-client-capability error and complete the ladder status table Adds MissingRequiredClientCapabilityError (-32003) with data.requiredCapabilities, recognized by ProtocolError.fromError from the error code and data shape, plus the shared helpers for computing which required client capabilities a request's declared capabilities are missing (with a method-keyed requirement table for the pre-dispatch gate, currently empty). The ladder status table gains explicit parse-error and invalid-request rows; handler-originated codes keep answering in-band on HTTP 200 and invalid params stays unmapped (the envelope rung carries its own 400). --- packages/core/src/exports/public/index.ts | 7 +- packages/core/src/index.ts | 1 + .../shared/clientCapabilityRequirements.ts | 99 +++++++++++++++++++ .../core/src/shared/inboundClassification.ts | 8 +- packages/core/src/types/errors.ts | 49 ++++++++- packages/core/src/types/types.ts | 13 +++ .../clientCapabilityRequirements.test.ts | 60 +++++++++++ .../test/shared/errorHttpStatusMatrix.test.ts | 98 ++++++++++++++++++ .../shared/inboundLadderCellSheet.test.ts | 6 ++ .../missingClientCapabilityError.test.ts | 64 ++++++++++++ 10 files changed, 401 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/shared/clientCapabilityRequirements.ts create mode 100644 packages/core/test/shared/clientCapabilityRequirements.test.ts create mode 100644 packages/core/test/shared/errorHttpStatusMatrix.test.ts create mode 100644 packages/core/test/types/missingClientCapabilityError.test.ts diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index ec0be8986c..88b806707f 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -94,7 +94,12 @@ export { export { ProtocolErrorCode } from '../../types/enums.js'; // Error classes -export { ProtocolError, UnsupportedProtocolVersionError, UrlElicitationRequiredError } from '../../types/errors.js'; +export { + MissingRequiredClientCapabilityError, + ProtocolError, + UnsupportedProtocolVersionError, + UrlElicitationRequiredError +} from '../../types/errors.js'; // Type guards and message parsing export { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e85490f204..4d9ac50250 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,7 @@ export * from './auth/errors.js'; export * from './errors/sdkErrors.js'; export * from './shared/auth.js'; export * from './shared/authUtils.js'; +export * from './shared/clientCapabilityRequirements.js'; export * from './shared/envelope.js'; export * from './shared/inboundClassification.js'; export * from './shared/metadataUtils.js'; diff --git a/packages/core/src/shared/clientCapabilityRequirements.ts b/packages/core/src/shared/clientCapabilityRequirements.ts new file mode 100644 index 0000000000..19c5b1a310 --- /dev/null +++ b/packages/core/src/shared/clientCapabilityRequirements.ts @@ -0,0 +1,99 @@ +/** + * Client-capability requirements for inbound requests (protocol revision + * 2026-07-28). + * + * The 2026-07-28 revision carries the client's declared capabilities on every + * request (`io.modelcontextprotocol/clientCapabilities`), and a server MUST + * NOT rely on capabilities the client did not declare: when processing a + * request requires an undeclared capability, the server answers + * `MissingRequiredClientCapabilityError` (`-32003`) with + * `data.requiredCapabilities` listing what is missing — HTTP status `400` on + * HTTP transports. + * + * This module is the shared, pure half of that rule. It is written for three + * call sites: + * + * 1. the pre-dispatch feature gate at the HTTP entry (a request to a method + * whose processing structurally requires a client capability is refused + * before dispatch), + * 2. the outbound input-request leg of multi round-trip requests (a server + * must not embed an input request the client cannot satisfy) — lands with + * the input-request engine, + * 3. the legacy-session pre-check before bridging input requests onto a + * 2025-era session — lands with that bridge. + * + * All three share {@linkcode missingClientCapabilities}; the per-method + * requirement table below feeds call site 1 only. + */ +import type { ClientCapabilities } from '../types/types.js'; + +/** + * Inbound request methods whose processing structurally requires a client + * capability, keyed by method, valued by the capabilities required. + * + * Currently empty: none of the request methods served on the 2026-07-28 + * registry unconditionally requires a client capability. Entries appear here + * when such methods exist — for example requests whose handling embeds + * elicitation or sampling input requests (the input-request engine), or + * opt-in subscription delivery. Handler-conditional requirements (a specific + * tool that needs sampling) are not expressible as a static method table and + * are enforced at the point the requirement arises instead. + */ +export const REQUIRED_CLIENT_CAPABILITIES_BY_METHOD: Readonly> = {}; + +/** + * The client capabilities a request method structurally requires, or + * `undefined` when the method has no static requirement. + */ +export function requiredClientCapabilitiesForRequest(method: string): ClientCapabilities | undefined { + return Object.hasOwn(REQUIRED_CLIENT_CAPABILITIES_BY_METHOD, method) ? REQUIRED_CLIENT_CAPABILITIES_BY_METHOD[method] : undefined; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Computes the subset of `required` client capabilities the client did not + * declare. Returns `undefined` when every required capability is declared; + * otherwise returns an object in the `ClientCapabilities` shape containing + * exactly the missing capabilities (suitable for + * `data.requiredCapabilities` on the `-32003` error). + * + * A capability counts as declared when its top-level key is present on the + * declared capabilities; when the requirement names nested members (for + * example `elicitation: { url: {} }`), each named member must also be present + * under the declared capability. An absent or empty `declared` value means + * nothing is declared — every required capability is missing (the structural + * clean-refusal posture for sessions with no per-request capability view). + */ +export function missingClientCapabilities( + required: ClientCapabilities, + declared: ClientCapabilities | undefined +): ClientCapabilities | undefined { + const missing: Record = {}; + + for (const [capability, requirement] of Object.entries(required)) { + if (requirement === undefined) { + continue; + } + const declaredValue = declared === undefined ? undefined : (declared as Record)[capability]; + if (declaredValue === undefined) { + missing[capability] = requirement; + continue; + } + if (isPlainObject(requirement) && isPlainObject(declaredValue)) { + const missingMembers: Record = {}; + for (const [member, memberRequirement] of Object.entries(requirement)) { + if (memberRequirement !== undefined && declaredValue[member] === undefined) { + missingMembers[member] = memberRequirement; + } + } + if (Object.keys(missingMembers).length > 0) { + missing[capability] = missingMembers; + } + } + } + + return Object.keys(missing).length > 0 ? (missing as ClientCapabilities) : undefined; +} diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index 4b14f18ee0..693f31a87c 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -249,8 +249,10 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor 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.' + 'Capability assertion runs after envelope validation and method resolution, immediately before the handler. The ' + + 'emission is performed by the HTTP entry before dispatch (the requirement table is method-keyed, so a request ' + + 'answered by an earlier rung never reaches it), pinning the spec-mandated HTTP 400 independently of how dispatch- ' + + 'and handler-produced errors are mapped.' } ]; @@ -273,6 +275,8 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor * handler-produced invalid-params error is always in-band. */ export const LADDER_ERROR_HTTP_STATUS: Readonly> = { + [ProtocolErrorCode.ParseError]: 400, + [ProtocolErrorCode.InvalidRequest]: 400, [ProtocolErrorCode.MethodNotFound]: 404, [ProtocolErrorCode.UnsupportedProtocolVersion]: 400, [ProtocolErrorCode.MissingRequiredClientCapability]: 400, diff --git a/packages/core/src/types/errors.ts b/packages/core/src/types/errors.ts index a175686d13..0ead600dbb 100644 --- a/packages/core/src/types/errors.ts +++ b/packages/core/src/types/errors.ts @@ -1,5 +1,10 @@ import { ProtocolErrorCode } from './enums.js'; -import type { ElicitRequestURLParams, UnsupportedProtocolVersionErrorData } from './types.js'; +import type { + ClientCapabilities, + ElicitRequestURLParams, + MissingRequiredClientCapabilityErrorData, + UnsupportedProtocolVersionErrorData +} from './types.js'; /** * Protocol errors are JSON-RPC errors that cross the wire as error responses. @@ -34,6 +39,17 @@ export class ProtocolError extends Error { } } + if (code === ProtocolErrorCode.MissingRequiredClientCapability && data) { + const errorData = data as Partial; + if ( + errorData.requiredCapabilities !== null && + typeof errorData.requiredCapabilities === 'object' && + !Array.isArray(errorData.requiredCapabilities) + ) { + return new MissingRequiredClientCapabilityError({ requiredCapabilities: errorData.requiredCapabilities }, message); + } + } + // Default to generic ProtocolError return new ProtocolError(code, message, data); } @@ -83,3 +99,34 @@ export class UnsupportedProtocolVersionError extends ProtocolError { return (this.data as UnsupportedProtocolVersionErrorData).requested; } } + +/** + * Error type for the `-32003` MissingRequiredClientCapability protocol error + * (protocol revision 2026-07-28): processing the request requires a capability + * the client did not declare in the request's `clientCapabilities`. + * + * The error data lists the missing capabilities (`requiredCapabilities`) in + * the `ClientCapabilities` shape, so the client can see exactly what it would + * have to declare for the request to be served. On HTTP, the response status + * is `400 Bad Request`. + * + * Recognize this error by its code and `data.requiredCapabilities` rather than + * by class identity (`instanceof` does not work across separately bundled + * copies of the SDK). + */ +export class MissingRequiredClientCapabilityError extends ProtocolError { + constructor( + data: MissingRequiredClientCapabilityErrorData, + message: string = `Missing required client capabilities: ${Object.keys(data.requiredCapabilities).join(', ')}` + ) { + super(ProtocolErrorCode.MissingRequiredClientCapability, message, data); + } + + /** + * The capabilities the server requires from the client to process the + * request (only the missing capabilities are listed). + */ + get requiredCapabilities(): ClientCapabilities { + return (this.data as MissingRequiredClientCapabilityErrorData).requiredCapabilities; + } +} diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index 9af48586c2..55b2bf7481 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -538,6 +538,19 @@ export interface InternalError extends JSONRPCErrorObject { code: typeof INTERNAL_ERROR; } +/** + * Data carried by a `-32003` MissingRequiredClientCapability protocol error + * (protocol revision 2026-07-28). + */ +export interface MissingRequiredClientCapabilityErrorData { + /** + * The capabilities the server requires from the client to process the + * request, in the `ClientCapabilities` shape (only the missing + * capabilities are listed). + */ + requiredCapabilities: ClientCapabilities; +} + /** * Data carried by a `-32004` UnsupportedProtocolVersion protocol error * (protocol revision 2026-07-28). diff --git a/packages/core/test/shared/clientCapabilityRequirements.test.ts b/packages/core/test/shared/clientCapabilityRequirements.test.ts new file mode 100644 index 0000000000..9b4c607586 --- /dev/null +++ b/packages/core/test/shared/clientCapabilityRequirements.test.ts @@ -0,0 +1,60 @@ +/** + * The shared client-capability requirement helpers behind the `-32003` + * MissingRequiredClientCapability rule (protocol revision 2026-07-28). + * + * `missingClientCapabilities` is the single helper shared by the pre-dispatch + * feature gate at the HTTP entry, the outbound input-request leg of multi + * round-trip requests, and the legacy-session pre-check; the per-method + * requirement table feeds the entry gate only. + */ +import { describe, expect, test } from 'vitest'; + +import { + missingClientCapabilities, + REQUIRED_CLIENT_CAPABILITIES_BY_METHOD, + requiredClientCapabilitiesForRequest +} from '../../src/shared/clientCapabilityRequirements.js'; +import { rev2026RequestMethods } from '../../src/wire/rev2026-07-28/registry.js'; + +describe('missingClientCapabilities', () => { + test('an undeclared capability view (no envelope, empty session state) misses everything required — the structural clean refusal', () => { + expect(missingClientCapabilities({ sampling: {} }, undefined)).toEqual({ sampling: {} }); + expect(missingClientCapabilities({ sampling: {}, elicitation: {} }, {})).toEqual({ sampling: {}, elicitation: {} }); + }); + + test('declared top-level capabilities satisfy top-level requirements', () => { + expect(missingClientCapabilities({ sampling: {} }, { sampling: {} })).toBeUndefined(); + }); + + test('only the missing subset is reported', () => { + expect(missingClientCapabilities({ sampling: {}, elicitation: {} }, { sampling: {} })).toEqual({ elicitation: {} }); + }); + + test('a requirement naming nested members needs each named member declared', () => { + expect(missingClientCapabilities({ elicitation: { url: {} } }, { elicitation: {} })).toEqual({ elicitation: { url: {} } }); + expect(missingClientCapabilities({ elicitation: { url: {} } }, { elicitation: { url: {} } })).toBeUndefined(); + expect(missingClientCapabilities({ elicitation: { url: {} } }, { elicitation: { form: {}, url: {} } })).toBeUndefined(); + }); + + test('an empty requirement object is always satisfied', () => { + expect(missingClientCapabilities({}, undefined)).toBeUndefined(); + }); +}); + +describe('requiredClientCapabilitiesForRequest', () => { + test('no method served on the 2026-07-28 registry has a static capability requirement today (the table is empty)', () => { + // This pin burns when a request method with a structural client-capability + // requirement is added (for example by the input-request engine or opt-in + // subscription delivery): add the entry, then update this expectation and + // cover the new cell. + expect(Object.keys(REQUIRED_CLIENT_CAPABILITIES_BY_METHOD)).toEqual([]); + for (const method of rev2026RequestMethods) { + expect(requiredClientCapabilitiesForRequest(method)).toBeUndefined(); + } + }); + + test('prototype-chain names never resolve to a requirement', () => { + expect(requiredClientCapabilitiesForRequest('constructor')).toBeUndefined(); + expect(requiredClientCapabilitiesForRequest('hasOwnProperty')).toBeUndefined(); + }); +}); diff --git a/packages/core/test/shared/errorHttpStatusMatrix.test.ts b/packages/core/test/shared/errorHttpStatusMatrix.test.ts new file mode 100644 index 0000000000..6cab1c46d0 --- /dev/null +++ b/packages/core/test/shared/errorHttpStatusMatrix.test.ts @@ -0,0 +1,98 @@ +/** + * The error→HTTP status matrix for the modern (2026-07-28) HTTP serving path, + * pinned at the table level (`LADDER_ERROR_HTTP_STATUS` / + * `httpStatusForErrorCode`). The mapping is keyed on ORIGIN, not on the bare + * code: + * + * - errors produced by the validation ladder or a pre-handler protocol gate + * map through the table (`-32601` → 404; the small mandated 400 set); + * - everything a request handler produces — whatever its code, including + * `-32603`, `-32602` and domain-specific codes — stays in-band on HTTP 200, + * never a blanket 500; + * - `-32602` deliberately has no table entry: the classifier's envelope rung + * carries its own HTTP 400 and is the only invalid-params rejection that + * maps to 400. + * + * Cells whose error CODE is still disputed upstream (the header/body mismatch + * family) stay parameterized: the emitted code is asserted as candidate-set + * membership, never a pinned literal. + * + * Transport- and dispatch-level behavior for these cells is covered by the + * ladder cell sheet and the per-request transport suites; this file pins the + * table itself. + */ +import { describe, expect, test } from 'vitest'; + +import { + httpStatusForErrorCode, + LADDER_ERROR_HTTP_STATUS, + PROVISIONAL_CROSS_CHECK_MISMATCH_CODE +} from '../../src/shared/inboundClassification.js'; +import { ProtocolErrorCode } from '../../src/types/enums.js'; + +describe('the status matrix — pinned cells', () => { + const PINNED_LADDER_CELLS: ReadonlyArray<{ code: number; status: number; cell: string }> = [ + { + code: ProtocolErrorCode.MethodNotFound, + status: 404, + cell: 'unknown or era-removed method (including a post-dispatch registry miss)' + }, + { code: ProtocolErrorCode.UnsupportedProtocolVersion, status: 400, cell: 'unsupported protocol version' }, + { code: ProtocolErrorCode.MissingRequiredClientCapability, status: 400, cell: 'missing required client capability' }, + { code: -32_001, status: 400, cell: 'header mismatch family (when emitted by the ladder)' }, + { code: ProtocolErrorCode.ParseError, status: 400, cell: 'unparseable request body' }, + { code: ProtocolErrorCode.InvalidRequest, status: 400, cell: 'malformed JSON-RPC body / rejected batch' } + ]; + + test.each(PINNED_LADDER_CELLS.map(row => [row.cell, row]))('%s', (_cell, row) => { + expect(LADDER_ERROR_HTTP_STATUS[row.code]).toBe(row.status); + expect(httpStatusForErrorCode(row.code, 'ladder')).toBe(row.status); + }); + + test('every code stays in-band on HTTP 200 when handler-originated — including internal errors and domain codes', () => { + const handlerCodes = [ + ProtocolErrorCode.InternalError, + ProtocolErrorCode.InvalidParams, + ProtocolErrorCode.MethodNotFound, + ProtocolErrorCode.ResourceNotFound, + ProtocolErrorCode.UrlElicitationRequired, + -32_000, + -1, + 12_345 + ]; + for (const code of handlerCodes) { + expect(httpStatusForErrorCode(code, 'in-band')).toBe(200); + } + }); + + test('-32603 never becomes a blanket 500: handler-originated internal errors are in-band', () => { + expect(LADDER_ERROR_HTTP_STATUS[ProtocolErrorCode.InternalError]).toBeUndefined(); + expect(httpStatusForErrorCode(ProtocolErrorCode.InternalError, 'in-band')).toBe(200); + }); + + test('-32602 has no table entry: the envelope rung short-circuit is the only invalid-params source of HTTP 400', () => { + expect(LADDER_ERROR_HTTP_STATUS[ProtocolErrorCode.InvalidParams]).toBeUndefined(); + expect(httpStatusForErrorCode(ProtocolErrorCode.InvalidParams, 'in-band')).toBe(200); + }); + + test('the table is exactly the mandated set (no silent growth)', () => { + expect( + Object.keys(LADDER_ERROR_HTTP_STATUS) + .map(Number) + .sort((a, b) => a - b) + ).toEqual([-32_700, -32_601, -32_600, -32_004, -32_003, -32_001].sort((a, b) => a - b)); + }); +}); + +describe('the status matrix — parameterized (disputed) cells', () => { + test('the header/body mismatch family code is a candidate, not a pin, and maps to 400 whichever candidate it is', () => { + const candidates = [-32_001, ProtocolErrorCode.InvalidParams, ProtocolErrorCode.UnsupportedProtocolVersion]; + expect(candidates).toContain(PROVISIONAL_CROSS_CHECK_MISMATCH_CODE); + // Whatever the upstream resolution turns out to be, a ladder-originated + // rejection in this family answers HTTP 400: every candidate either has + // a 400 row or is carried by the classifier's own httpStatus. + if (PROVISIONAL_CROSS_CHECK_MISMATCH_CODE !== ProtocolErrorCode.InvalidParams) { + expect(httpStatusForErrorCode(PROVISIONAL_CROSS_CHECK_MISMATCH_CODE, 'ladder')).toBe(400); + } + }); +}); diff --git a/packages/core/test/shared/inboundLadderCellSheet.test.ts b/packages/core/test/shared/inboundLadderCellSheet.test.ts index 3c66e933db..60abd5cf40 100644 --- a/packages/core/test/shared/inboundLadderCellSheet.test.ts +++ b/packages/core/test/shared/inboundLadderCellSheet.test.ts @@ -413,8 +413,14 @@ describe('the validation ladder as data', () => { describe('HTTP status mapping for ladder-originated errors (origin-keyed)', () => { test('the table maps exactly the ladder-originated codes', () => { + // The parse-error and invalid-request rows joined the table when the + // status matrix was completed alongside the cache fill / capability + // gate work; they were previously carried only by the classifier's own + // httpStatus on the rejection outcomes (same 400, now table-visible). expect(LADDER_ERROR_HTTP_STATUS).toEqual({ + [-32_700]: 400, [-32_601]: 404, + [-32_600]: 400, [-32_004]: 400, [-32_003]: 400, [-32_001]: 400 diff --git a/packages/core/test/types/missingClientCapabilityError.test.ts b/packages/core/test/types/missingClientCapabilityError.test.ts new file mode 100644 index 0000000000..1d15ad1dae --- /dev/null +++ b/packages/core/test/types/missingClientCapabilityError.test.ts @@ -0,0 +1,64 @@ +/** + * The `-32003` MissingRequiredClientCapability typed error. + * + * Recognition is data-parse based: a peer (or another bundled copy of the SDK) + * is recognized by the error code plus the `data.requiredCapabilities` shape, + * never by `instanceof` across bundles. + */ +import { describe, expect, test } from 'vitest'; + +import { ProtocolErrorCode } from '../../src/types/enums.js'; +import { MissingRequiredClientCapabilityError, ProtocolError } from '../../src/types/errors.js'; + +describe('MissingRequiredClientCapabilityError', () => { + test('carries the -32003 code and the missing capabilities in data.requiredCapabilities', () => { + const error = new MissingRequiredClientCapabilityError({ requiredCapabilities: { sampling: {}, elicitation: { url: {} } } }); + expect(error.code).toBe(ProtocolErrorCode.MissingRequiredClientCapability); + expect(error.code).toBe(-32_003); + expect(error.requiredCapabilities).toEqual({ sampling: {}, elicitation: { url: {} } }); + expect(error.data).toEqual({ requiredCapabilities: { sampling: {}, elicitation: { url: {} } } }); + expect(error.message).toContain('sampling'); + expect(error.message).toContain('elicitation'); + }); + + test('a custom message is preserved', () => { + const error = new MissingRequiredClientCapabilityError({ requiredCapabilities: { sampling: {} } }, 'declare sampling first'); + expect(error.message).toBe('declare sampling first'); + }); + + test('fromError recognizes the code + data shape (the cross-bundle data-parse path)', () => { + // Simulates an error received from the wire or from a separately + // bundled SDK copy: plain code/message/data, no class identity. + const wireShape = { + code: -32_003, + message: 'Missing required client capabilities: sampling', + data: { requiredCapabilities: { sampling: {} } } + }; + const recognized = ProtocolError.fromError(wireShape.code, wireShape.message, wireShape.data); + expect(recognized).toBeInstanceOf(MissingRequiredClientCapabilityError); + expect((recognized as MissingRequiredClientCapabilityError).requiredCapabilities).toEqual({ sampling: {} }); + }); + + test('fromError falls back to the generic ProtocolError when the data shape does not match', () => { + expect(ProtocolError.fromError(-32_003, 'missing', undefined)).not.toBeInstanceOf(MissingRequiredClientCapabilityError); + expect(ProtocolError.fromError(-32_003, 'missing', { requiredCapabilities: ['sampling'] })).not.toBeInstanceOf( + MissingRequiredClientCapabilityError + ); + expect(ProtocolError.fromError(-32_003, 'missing', { somethingElse: true })).not.toBeInstanceOf( + MissingRequiredClientCapabilityError + ); + expect(ProtocolError.fromError(-32_003, 'missing', { requiredCapabilities: { sampling: {} } })).toBeInstanceOf( + MissingRequiredClientCapabilityError + ); + }); + + test('recognition by code and data shape works on plain values (no instanceof needed)', () => { + const fromAnotherBundle: { code: number; data?: unknown } = new MissingRequiredClientCapabilityError({ + requiredCapabilities: { sampling: {} } + }); + const looksLikeMissingCapability = + fromAnotherBundle.code === -32_003 && + typeof (fromAnotherBundle.data as { requiredCapabilities?: unknown } | undefined)?.requiredCapabilities === 'object'; + expect(looksLikeMissingCapability).toBe(true); + }); +}); From be27e5abb0f3014e56c48a17ee10a449121356b2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 04:16:20 +0000 Subject: [PATCH 02/14] feat(core): fill the required cache fields at the 2026-era result encode seam The 2026-era codec's encodeResult now applies the outbound encode contract as two pure steps: the resultType stamp (handler-provided values pass through only for the multi round-trip methods whose result vocabulary goes beyond 'complete'; a stray non-complete value elsewhere fails loudly) followed by the cache fill, which gives complete results of the six cacheable operations the revision's required ttlMs and cacheScope fields. Values are resolved most specific author first: valid handler-returned fields, then a configured cache hint attached by the server layer through a never-serialized symbol carrier, then the conservative defaults (ttlMs 0, cacheScope private). The 2025-era codec remains the identity, and a suppression suite pins what is never stamped: legacy traffic, input_required results, non-cacheable operations, era-removed methods, client-emitted requests, and error responses. --- packages/core/src/index.ts | 1 + packages/core/src/shared/resultCacheHints.ts | 119 ++++++++ packages/core/src/wire/codec.ts | 8 +- packages/core/src/wire/rev2026-07-28/codec.ts | 23 +- .../src/wire/rev2026-07-28/encodeContract.ts | 123 ++++++++ .../core/test/wire/encodeContract.test.ts | 192 +++++++++++++ .../test/wire/stampingSuppression.test.ts | 270 ++++++++++++++++++ 7 files changed, 721 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/shared/resultCacheHints.ts create mode 100644 packages/core/src/wire/rev2026-07-28/encodeContract.ts create mode 100644 packages/core/test/wire/encodeContract.test.ts create mode 100644 packages/core/test/wire/stampingSuppression.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4d9ac50250..b74b370335 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,7 @@ export * from './shared/inboundClassification.js'; export * from './shared/metadataUtils.js'; export * from './shared/protocol.js'; export * from './shared/protocolEras.js'; +export * from './shared/resultCacheHints.js'; export * from './shared/stdio.js'; export * from './shared/toolNameValidation.js'; export * from './shared/transport.js'; diff --git a/packages/core/src/shared/resultCacheHints.ts b/packages/core/src/shared/resultCacheHints.ts new file mode 100644 index 0000000000..3461fc4849 --- /dev/null +++ b/packages/core/src/shared/resultCacheHints.ts @@ -0,0 +1,119 @@ +/** + * Cache-hint plumbing for cacheable results (protocol revision 2026-07-28). + * + * The 2026-07-28 revision requires `ttlMs`/`cacheScope` on the cacheable + * result types (SEP-2549 `CacheableResult`). The values are resolved at the + * era-aware encode seam (the 2026 wire codec's `encodeResult`), most specific + * author first: + * + * 1. fields the handler returned on the result itself (when valid), + * 2. a configured cache hint attached by the server layer + * (per-registration hint, then the server-level per-operation hint), + * 3. the conservative defaults `{ ttlMs: 0, cacheScope: 'private' }`. + * + * The configured hint travels from the (era-blind) server configuration to the + * (era-aware) encode seam on a symbol-keyed property of the result object — + * {@linkcode RESULT_CACHE_HINT_FALLBACK}. Symbol-keyed properties are never + * serialized to JSON, so attaching a hint can never change what a 2025-era + * response looks like on the wire: only the 2026-era codec reads (and removes) + * it while filling the required fields. The 2025-era codec has no cache code + * path at all. + */ + +/** The cache scopes defined for cacheable results (SEP-2549). */ +export type CacheScope = 'public' | 'private'; + +/** + * A cache hint for a cacheable result (protocol revision 2026-07-28): the + * values to emit for `ttlMs` / `cacheScope` when the handler does not provide + * them itself. Absent fields fall back to the conservative defaults + * (`ttlMs: 0`, `cacheScope: 'private'`). + */ +export interface CacheHint { + /** Cache lifetime in milliseconds. Must be a non-negative integer. */ + ttlMs?: number; + /** Whether the result may be cached by shared caches (`public`) or only by the requesting client (`private`). */ + cacheScope?: CacheScope; +} + +/** + * The operations whose results are cacheable on the 2026-07-28 revision (the + * `CacheableResult` extenders). This list is closed: no other operation's + * result ever receives cache fields from the SDK. + */ +export const CACHEABLE_RESULT_METHODS = [ + 'tools/list', + 'prompts/list', + 'resources/list', + 'resources/templates/list', + 'resources/read', + 'server/discover' +] as const; + +/** A method whose result is cacheable on the 2026-07-28 revision. */ +export type CacheableResultMethod = (typeof CACHEABLE_RESULT_METHODS)[number]; + +/** Whether the given method's result is cacheable on the 2026-07-28 revision. */ +export function isCacheableResultMethod(method: string): method is CacheableResultMethod { + return (CACHEABLE_RESULT_METHODS as readonly string[]).includes(method); +} + +/** + * The symbol-keyed carrier for a configured cache hint on a result object. + * Symbol properties are invisible to JSON serialization, so the carrier can be + * attached era-blind: only the 2026-era encode seam consumes it. + */ +export const RESULT_CACHE_HINT_FALLBACK: unique symbol = Symbol('modelcontextprotocol.resultCacheHintFallback'); + +/** A result object that may carry a configured cache-hint fallback. */ +interface CacheHintCarrier { + [RESULT_CACHE_HINT_FALLBACK]?: CacheHint; +} + +/** + * Attaches a configured cache hint to a result as the encode-time fallback. + * Returns the result unchanged when there is nothing to attach or when a more + * specific hint is already attached (most-specific-author-wins: a + * per-registration hint attached by the feature layer is never overwritten by + * the server-level per-operation hint). + */ +export function attachCacheHintFallback(result: T, hint: CacheHint | undefined): T { + if (hint === undefined) { + return result; + } + if ((result as CacheHintCarrier)[RESULT_CACHE_HINT_FALLBACK] !== undefined) { + return result; + } + return { ...result, [RESULT_CACHE_HINT_FALLBACK]: hint }; +} + +/** Reads the configured cache-hint fallback attached to a result, if any. */ +export function cacheHintFallbackOf(result: object): CacheHint | undefined { + return (result as CacheHintCarrier)[RESULT_CACHE_HINT_FALLBACK]; +} + +/** Whether a value is a valid `ttlMs`: a non-negative, finite integer. */ +export function isValidCacheTtlMs(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0; +} + +/** Whether a value is a valid `cacheScope`. */ +export function isValidCacheScope(value: unknown): value is CacheScope { + return value === 'public' || value === 'private'; +} + +/** + * Validates a configured cache hint at configuration time. Throws a + * `RangeError` naming the offending field, so misconfiguration fails at + * startup/registration rather than silently degrading at encode time. + */ +export function assertValidCacheHint(hint: CacheHint, context: string): void { + if (hint.ttlMs !== undefined && !isValidCacheTtlMs(hint.ttlMs)) { + throw new RangeError(`Invalid cache hint for ${context}: ttlMs must be a non-negative integer (got ${String(hint.ttlMs)})`); + } + if (hint.cacheScope !== undefined && !isValidCacheScope(hint.cacheScope)) { + throw new RangeError( + `Invalid cache hint for ${context}: cacheScope must be 'public' or 'private' (got ${String(hint.cacheScope)})` + ); + } +} diff --git a/packages/core/src/wire/codec.ts b/packages/core/src/wire/codec.ts index 586cb2e044..d98d8e23f1 100644 --- a/packages/core/src/wire/codec.ts +++ b/packages/core/src/wire/codec.ts @@ -144,10 +144,10 @@ export interface WireCodec { /** * Outbound result mapping (the stamp seam). The 2025-era codec is the * identity — it has NO stamp code path (the never-stamp guarantee). The - * 2026-era codec stamps `resultType` and strictly enforces the 2026 wire - * shape for the known deleted-field set (`execution.taskSupport`, - * `capabilities.tasks` — Q1-SD3 iii). ttlMs/cacheScope stamping content - * is M3.2 scope and lands in this seam. + * 2026-era codec strictly enforces the 2026 wire shape for the known + * deleted-field set (`execution.taskSupport`, `capabilities.tasks` — + * Q1-SD3 iii), stamps `resultType`, and fills the required + * `ttlMs`/`cacheScope` fields on cacheable results. */ encodeResult(method: string, result: Result): Result; diff --git a/packages/core/src/wire/rev2026-07-28/codec.ts b/packages/core/src/wire/rev2026-07-28/codec.ts index 9e3e4f25ef..4410a0a05b 100644 --- a/packages/core/src/wire/rev2026-07-28/codec.ts +++ b/packages/core/src/wire/rev2026-07-28/codec.ts @@ -5,12 +5,14 @@ * the RAW value is inspected BEFORE any schema validation, so a non-complete * result can never be masked into a hollow success by a tolerant schema), * then wire-exact parse, then lift (drop the wire member). Encode = the - * stamp seam: `resultType: 'complete'` is stamped on outbound results, and - * the known deleted-field set is strictly enforced (Q1-SD3 iii) — the 2026 - * wire types have no slot for `execution.taskSupport` or + * stamp seam: the known deleted-field set is strictly enforced (Q1-SD3 iii) — + * the 2026 wire types have no slot for `execution.taskSupport` or * `capabilities.tasks`, so the encode mapping deletes them; era-blind * handlers stay era-invisible while deleted vocabulary cannot cross eras - * through the parse-free outbound path. + * through the parse-free outbound path — and then the encode contract steps + * run (see `encodeContract.ts`): the `resultType` stamp (with handler + * pass-through for the multi round-trip methods) followed by the required + * `ttlMs`/`cacheScope` fill on cacheable results. * * Q1-SD3 postures implemented here: * (i) absent `resultType` from a 2026-classified peer → typed error NAMING @@ -22,15 +24,13 @@ * driver, M4.1/#13, consumes it; until then the protocol layer surfaces * the discriminated kind as a typed local error, no retry). * (iii) unrecognized kinds → invalid, no retry (DQ5). - * - * The ttlMs/cacheScope stamping content (M3.2) lands in `encodeResult` — - * this seam is its final home. */ import type * as z from 'zod/v4'; import { SdkError, SdkErrorCode } from '../../errors/sdkErrors.js'; import type { Result } from '../../types/types.js'; import type { DecodedResult, LiftedWireMaterial, WireCodec } from '../codec.js'; +import { fillCacheFields, stampResultType } from './encodeContract.js'; import { getNotificationSchema2026, getRequestSchema2026, @@ -172,10 +172,11 @@ export const rev2026Codec: WireCodec = { }, encodeResult(method: string, result: Result): Result { - // The stamp seam: outbound results carry the required discriminator. - // (Handler-authored resultType for methods whose vocabulary exceeds - // 'complete' is MRTR scope — #13 extends this seam.) - return { ...enforceDeletedFields(method, result), resultType: 'complete' } as Result; + // The stamp seam, in pinned order: deleted-field strictness, then the + // resultType stamp (handler pass-through only for methods whose + // vocabulary goes beyond 'complete'), then the cache fill for the + // cacheable operations (only on post-stamp 'complete' results). + return fillCacheFields(method, stampResultType(method, enforceDeletedFields(method, result))); }, checkInboundEnvelope(material: LiftedWireMaterial): string | undefined { diff --git a/packages/core/src/wire/rev2026-07-28/encodeContract.ts b/packages/core/src/wire/rev2026-07-28/encodeContract.ts new file mode 100644 index 0000000000..36f12a989a --- /dev/null +++ b/packages/core/src/wire/rev2026-07-28/encodeContract.ts @@ -0,0 +1,123 @@ +/** + * The outbound result encode contract for the 2026-07-28 wire codec, as pure, + * individually-testable steps. `encodeResult` applies them in order: + * + * 1. {@linkcode stampResultType} — the `resultType` discriminator. The SDK + * stamps `'complete'`; a handler-provided value passes through only for + * methods whose spec result vocabulary goes beyond `'complete'` (the + * multi round-trip request methods, whose results may be + * `input_required`). A non-`'complete'` value returned by a handler for + * any other method is a server bug and fails loudly (internal error) + * rather than being mis-typed on the wire. + * 2. {@linkcode fillCacheFields} — the required `ttlMs`/`cacheScope` fields + * on cacheable results (SEP-2549), filled only when the post-stamp + * `resultType` is `'complete'` and the method is one of the cacheable + * operations. Resolution is most-specific-author-first: valid + * handler-returned values, then the configured cache hint attached by the + * server layer, then the conservative defaults + * `{ ttlMs: 0, cacheScope: 'private' }`. Invalid handler-returned values + * never reach the wire — they fall through to the next author. + * + * Ordering matters and is pinned by tests: the stamp runs before the fill, so + * an `input_required` result is never given cache fields. + */ +import type { CacheHint } from '../../shared/resultCacheHints.js'; +import { + cacheHintFallbackOf, + isCacheableResultMethod, + isValidCacheScope, + isValidCacheTtlMs, + RESULT_CACHE_HINT_FALLBACK +} from '../../shared/resultCacheHints.js'; +import { ProtocolErrorCode } from '../../types/enums.js'; +import { ProtocolError } from '../../types/errors.js'; +import type { Result } from '../../types/types.js'; + +/** The default cache policy when neither the handler nor configuration provides one. */ +export const DEFAULT_CACHE_TTL_MS = 0; +export const DEFAULT_CACHE_SCOPE = 'private'; + +/** + * Request methods whose spec result vocabulary goes beyond `'complete'` on the + * 2026-07-28 revision: their results may be `input_required` (multi + * round-trip requests), so a handler-provided `resultType` passes through the + * stamp untouched. `subscriptions/listen` joins this set when the + * subscriptions feature is served (its terminal result uses the same + * mechanism). + */ +export const EXTENDED_RESULT_TYPE_METHODS: readonly string[] = ['tools/call', 'prompts/get', 'resources/read']; + +/** + * Step 1 of the encode contract: ensure the outbound result carries the + * required `resultType` discriminator. + * + * - No handler-provided value → stamp `'complete'`. + * - Handler-provided `'complete'` → kept as-is. + * - Handler-provided non-`'complete'` value on a method whose vocabulary + * allows it ({@linkcode EXTENDED_RESULT_TYPE_METHODS}) → passes through. + * - Handler-provided non-`'complete'` value on any other method → internal + * error (loud): the value would be mis-typed on the wire, and silently + * rewriting it would hide a server bug. + */ +export function stampResultType(method: string, result: Result): Result { + const provided = (result as Record)['resultType']; + if (provided === undefined) { + return { ...result, resultType: 'complete' } as Result; + } + if (provided === 'complete') { + return result; + } + if (EXTENDED_RESULT_TYPE_METHODS.includes(method)) { + return result; + } + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned resultType '${String(provided)}', but results of ${method} only support 'complete' on protocol revision 2026-07-28` + ); +} + +/** + * Step 2 of the encode contract: fill the required `ttlMs`/`cacheScope` fields + * on cacheable results. + * + * Applies only when the (post-stamp) `resultType` is `'complete'` and the + * method is one of the cacheable operations; everything else is returned + * untouched apart from removing the configured-hint carrier. Field resolution + * is per field, most specific author first: a valid handler-returned value, + * then the configured cache hint attached by the server layer, then the + * defaults. Handler-returned values are validated at encode time (`ttlMs` + * must be a non-negative integer, `cacheScope` must be `'public'` or + * `'private'`); invalid values are ignored rather than emitted. + */ +export function fillCacheFields(method: string, result: Result): Result { + const fallback = cacheHintFallbackOf(result); + const resultType = (result as Record)['resultType']; + + if (resultType !== 'complete' || !isCacheableResultMethod(method)) { + // Not a cache-fill target. Drop the configured-hint carrier if one was + // attached so it never travels past the encode seam. + return fallback === undefined ? result : stripCacheHintFallback(result); + } + + const provided = result as Record; + const ttlMs = isValidCacheTtlMs(provided['ttlMs']) ? (provided['ttlMs'] as number) : resolveTtlMs(fallback); + const cacheScope = isValidCacheScope(provided['cacheScope']) ? (provided['cacheScope'] as string) : resolveCacheScope(fallback); + + const filled = { ...provided, ttlMs, cacheScope } as Record; + delete filled[RESULT_CACHE_HINT_FALLBACK]; + return filled as Result; +} + +function resolveTtlMs(fallback: CacheHint | undefined): number { + return fallback !== undefined && isValidCacheTtlMs(fallback.ttlMs) ? fallback.ttlMs : DEFAULT_CACHE_TTL_MS; +} + +function resolveCacheScope(fallback: CacheHint | undefined): string { + return fallback !== undefined && isValidCacheScope(fallback.cacheScope) ? fallback.cacheScope : DEFAULT_CACHE_SCOPE; +} + +function stripCacheHintFallback(result: Result): Result { + const copy = { ...result } as Record; + delete copy[RESULT_CACHE_HINT_FALLBACK]; + return copy as Result; +} diff --git a/packages/core/test/wire/encodeContract.test.ts b/packages/core/test/wire/encodeContract.test.ts new file mode 100644 index 0000000000..53dcaffd2e --- /dev/null +++ b/packages/core/test/wire/encodeContract.test.ts @@ -0,0 +1,192 @@ +/** + * The 2026-07-28 outbound encode contract, tested as pure steps and through + * the codec's `encodeResult` integration: + * + * step 1 — resultType stamp: `'complete'` stamped when absent; a + * handler-provided value passes through only for methods whose spec + * result vocabulary goes beyond `'complete'` (the multi round-trip + * methods); a stray non-`'complete'` value anywhere else fails + * loudly instead of being mis-typed on the wire. + * step 2 — cache fill: `ttlMs`/`cacheScope` filled only on post-stamp + * `'complete'` results of the cacheable operations, resolved most + * specific author first (valid handler-returned values, then the + * attached configured hint, then the defaults), with an encode-time + * validity gate on handler-returned values. + * + * The ordering (stamp before fill, `input_required` excluded from the fill) + * is pinned here. + */ +import { describe, expect, test } from 'vitest'; + +import { + attachCacheHintFallback, + CACHEABLE_RESULT_METHODS, + cacheHintFallbackOf, + RESULT_CACHE_HINT_FALLBACK +} from '../../src/shared/resultCacheHints.js'; +import { ProtocolError } from '../../src/types/errors.js'; +import type { Result } from '../../src/types/types.js'; +import { rev2026Codec } from '../../src/wire/rev2026-07-28/codec.js'; +import { + DEFAULT_CACHE_SCOPE, + DEFAULT_CACHE_TTL_MS, + EXTENDED_RESULT_TYPE_METHODS, + fillCacheFields, + stampResultType +} from '../../src/wire/rev2026-07-28/encodeContract.js'; + +const asResult = (value: Record): Result => value as unknown as Result; +const fieldsOf = (value: Result): Record => value as unknown as Record; + +describe('step 1 — the resultType stamp', () => { + test("stamps 'complete' when the handler did not provide a resultType", () => { + const stamped = fieldsOf(stampResultType('tools/list', asResult({ tools: [] }))); + expect(stamped['resultType']).toBe('complete'); + }); + + test("keeps a handler-provided 'complete' as-is (same reference)", () => { + const result = asResult({ tools: [], resultType: 'complete' }); + expect(stampResultType('tools/list', result)).toBe(result); + }); + + test.each(EXTENDED_RESULT_TYPE_METHODS.map(method => [method]))( + 'passes a handler-provided input_required through for %s (extended result vocabulary)', + method => { + const result = asResult({ resultType: 'input_required', inputRequests: {} }); + expect(stampResultType(method, result)).toBe(result); + } + ); + + test('passes other handler-provided values through on extended-vocabulary methods (the wire vocabulary is an open union)', () => { + const result = asResult({ resultType: 'some_future_kind' }); + expect(stampResultType('tools/call', result)).toBe(result); + }); + + test.each([['tools/list'], ['prompts/list'], ['server/discover'], ['completion/complete']])( + 'a stray input_required from a handler for %s fails loudly with an internal error', + method => { + expect(() => stampResultType(method, asResult({ resultType: 'input_required' }))).toThrowError(ProtocolError); + try { + stampResultType(method, asResult({ resultType: 'input_required' })); + } catch (error) { + expect((error as ProtocolError).code).toBe(-32_603); + expect((error as ProtocolError).message).toContain(method); + } + } + ); + + test('the extended-vocabulary method set is exactly the multi round-trip request methods', () => { + expect([...EXTENDED_RESULT_TYPE_METHODS].sort()).toEqual(['prompts/get', 'resources/read', 'tools/call'].sort()); + }); +}); + +describe('step 2 — the cache fill', () => { + test('the cacheable-operation list is closed at exactly six operations', () => { + expect([...CACHEABLE_RESULT_METHODS].sort()).toEqual( + ['tools/list', 'prompts/list', 'resources/list', 'resources/templates/list', 'resources/read', 'server/discover'].sort() + ); + }); + + test.each(CACHEABLE_RESULT_METHODS.map(method => [method]))('fills the defaults on a complete %s result', method => { + const filled = fieldsOf(fillCacheFields(method, asResult({ resultType: 'complete' }))); + expect(filled['ttlMs']).toBe(DEFAULT_CACHE_TTL_MS); + expect(filled['cacheScope']).toBe(DEFAULT_CACHE_SCOPE); + }); + + test.each([['tools/call'], ['prompts/get'], ['completion/complete'], ['app/custom']])( + 'never fills cache fields for %s (not a cacheable operation)', + method => { + const filled = fieldsOf(fillCacheFields(method, asResult({ resultType: 'complete' }))); + expect('ttlMs' in filled).toBe(false); + expect('cacheScope' in filled).toBe(false); + } + ); + + test('input_required results are never given cache fields (stamp-before-fill ordering)', () => { + const filled = fieldsOf(fillCacheFields('resources/read', asResult({ resultType: 'input_required', inputRequests: {} }))); + expect('ttlMs' in filled).toBe(false); + expect('cacheScope' in filled).toBe(false); + }); + + test('valid handler-returned values are respected over the attached hint and the defaults', () => { + const result = attachCacheHintFallback(asResult({ resultType: 'complete', ttlMs: 30_000, cacheScope: 'public' }), { + ttlMs: 5_000, + cacheScope: 'private' + }); + const filled = fieldsOf(fillCacheFields('tools/list', result)); + expect(filled['ttlMs']).toBe(30_000); + expect(filled['cacheScope']).toBe('public'); + }); + + test('the attached configured hint wins over the defaults when the handler provided nothing', () => { + const result = attachCacheHintFallback(asResult({ resultType: 'complete' }), { ttlMs: 5_000, cacheScope: 'public' }); + const filled = fieldsOf(fillCacheFields('resources/read', result)); + expect(filled['ttlMs']).toBe(5_000); + expect(filled['cacheScope']).toBe('public'); + }); + + test('a partial hint fills only its own field; the other falls back to the default', () => { + const result = attachCacheHintFallback(asResult({ resultType: 'complete' }), { ttlMs: 9_000 }); + const filled = fieldsOf(fillCacheFields('server/discover', result)); + expect(filled['ttlMs']).toBe(9_000); + expect(filled['cacheScope']).toBe(DEFAULT_CACHE_SCOPE); + }); + + test.each([ + ['a negative ttlMs', { ttlMs: -1 }], + ['a non-integer ttlMs', { ttlMs: 1.5 }], + ['a non-numeric ttlMs', { ttlMs: 'soon' }], + ['an unknown cacheScope', { cacheScope: 'shared' }] + ])('invalid handler-returned values (%s) never reach the wire — the next author wins', (_label, invalid) => { + const result = attachCacheHintFallback(asResult({ resultType: 'complete', ...invalid }), { ttlMs: 1_000, cacheScope: 'public' }); + const filled = fieldsOf(fillCacheFields('tools/list', result)); + expect(filled['ttlMs']).toBe(1_000); + expect(filled['cacheScope']).toBe('public'); + }); + + test('the configured-hint carrier never survives past the encode seam', () => { + const filledTarget = fillCacheFields('tools/list', attachCacheHintFallback(asResult({ resultType: 'complete' }), { ttlMs: 1 })); + expect(cacheHintFallbackOf(filledTarget)).toBeUndefined(); + + const nonTarget = fillCacheFields('tools/call', attachCacheHintFallback(asResult({ resultType: 'complete' }), { ttlMs: 1 })); + expect(cacheHintFallbackOf(nonTarget)).toBeUndefined(); + expect(RESULT_CACHE_HINT_FALLBACK in (nonTarget as object)).toBe(false); + }); + + test('attachCacheHintFallback never overwrites an already-attached, more specific hint', () => { + const withSpecific = attachCacheHintFallback(asResult({}), { ttlMs: 2_000 }); + const withBoth = attachCacheHintFallback(withSpecific, { ttlMs: 50 }); + expect(cacheHintFallbackOf(withBoth)).toEqual({ ttlMs: 2_000 }); + }); +}); + +describe('the codec integration (encodeResult applies the contract in pinned order)', () => { + test('a complete cacheable result is stamped and filled', () => { + const encoded = fieldsOf(rev2026Codec.encodeResult('tools/list', asResult({ tools: [] }))); + expect(encoded).toMatchObject({ resultType: 'complete', ttlMs: DEFAULT_CACHE_TTL_MS, cacheScope: DEFAULT_CACHE_SCOPE }); + }); + + test('deleted-field strictness, stamp and fill compose on the same emission', () => { + const encoded = fieldsOf( + rev2026Codec.encodeResult( + 'tools/list', + asResult({ tools: [{ name: 't', inputSchema: { type: 'object' }, execution: { taskSupport: 'optional' } }] }) + ) + ); + expect(encoded).toMatchObject({ resultType: 'complete', ttlMs: 0, cacheScope: 'private' }); + expect('execution' in (encoded['tools'] as Array>)[0]!).toBe(false); + }); + + test('an input_required result from a multi round-trip method is passed through unfilled', () => { + const encoded = fieldsOf( + rev2026Codec.encodeResult('resources/read', asResult({ resultType: 'input_required', inputRequests: {} })) + ); + expect(encoded['resultType']).toBe('input_required'); + expect('ttlMs' in encoded).toBe(false); + expect('cacheScope' in encoded).toBe(false); + }); + + test('a stray input_required from a non-multi-round-trip handler throws out of encodeResult (answered as an internal error upstream)', () => { + expect(() => rev2026Codec.encodeResult('tools/list', asResult({ resultType: 'input_required' }))).toThrowError(ProtocolError); + }); +}); diff --git a/packages/core/test/wire/stampingSuppression.test.ts b/packages/core/test/wire/stampingSuppression.test.ts new file mode 100644 index 0000000000..80af02aacc --- /dev/null +++ b/packages/core/test/wire/stampingSuppression.test.ts @@ -0,0 +1,270 @@ +/** + * The stamping suppression suite: what is NEVER stamped. + * + * S1 — legacy-classified traffic is never stamped (structural: the 2025-era + * codec has no stamp or cache code path; encode is the identity). + * S2 — input_required results never carry cache fields. + * S3 — results of non-cacheable operations are never given cache fields; the + * cacheable-operation list is closed. + * S4 — era-removed (2025-only) methods are never stamped: they have no + * 2026-era registry entry, so they can never reach the 2026 encode + * seam, and their 2025-era responses are byte-untouched. + * S5 — stamping is response-side only: requests emitted by a 2026-era sender + * carry none of the result vocabulary. + * S6 — error responses are never stamped. + * + * Carve-out (documented leak note): cache fields AUTHORED BY THE CONSUMER on a + * 2025-era result pass through unchanged — the suite asserts the absence of + * SDK-stamped vocabulary only, because stripping consumer-authored fields + * would change deployed 2025-era behavior for no gain. + * + * Together with the 2025 codec identity pin, this suite is the evidence that + * this change produces zero 2025-era wire deltas. + */ +import { describe, expect, test } from 'vitest'; + +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol, setNegotiatedProtocolVersion } from '../../src/shared/protocol.js'; +import { attachCacheHintFallback, CACHEABLE_RESULT_METHODS } from '../../src/shared/resultCacheHints.js'; +import type { JSONRPCMessage, MessageClassification, Result } from '../../src/types/index.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; +import { rev2025Codec } from '../../src/wire/rev2025-11-25/codec.js'; +import { rev2026Codec } from '../../src/wire/rev2026-07-28/codec.js'; + +class TestProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +const MODERN: MessageClassification = { era: 'modern', revision: '2026-07-28' }; + +const ENVELOPE = { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'suppression-client', version: '0.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {} +}; + +/** The SDK-stamped result vocabulary the 2025 era must never gain. */ +const STAMPED_VOCABULARY = ['resultType', 'ttlMs', 'cacheScope'] as const; + +interface Harness { + receiver: TestProtocol; + deliver: (message: JSONRPCMessage, classification?: MessageClassification) => void; + sent: JSONRPCMessage[]; + flush: () => Promise; +} + +async function harness(options: { era?: '2026-07-28'; setup?: (receiver: TestProtocol) => void } = {}): Promise { + const [peerTx, receiverTx] = InMemoryTransport.createLinkedPair(); + const sent: JSONRPCMessage[] = []; + peerTx.onmessage = message => void sent.push(message); + await peerTx.start(); + + const receiver = new TestProtocol(); + receiver.onerror = () => {}; + options.setup?.(receiver); + if (options.era !== undefined) setNegotiatedProtocolVersion(receiver, options.era); + await receiver.connect(receiverTx); + + return { + receiver, + deliver: (message, classification) => receiverTx.onmessage?.(message, classification ? ({ classification } as never) : undefined), + sent, + flush: () => new Promise(resolve => setTimeout(resolve, 10)) + }; +} + +const resultOf = (msg: JSONRPCMessage | undefined) => (msg as { result?: Record } | undefined)?.result; +const errorOf = (msg: JSONRPCMessage | undefined) => (msg as { error?: { code: number; data?: unknown } } | undefined)?.error; + +function expectNoStampedVocabulary(value: unknown): void { + const json = JSON.stringify(value); + for (const key of STAMPED_VOCABULARY) { + expect(json).not.toContain(`"${key}"`); + } +} + +describe('S1 — legacy-classified traffic is never stamped', () => { + test('the 2025 codec encode is the identity for every cacheable operation, even with a configured hint attached', () => { + for (const method of CACHEABLE_RESULT_METHODS) { + const plain = { items: [] } as unknown as Result; + expect(rev2025Codec.encodeResult(method, plain)).toBe(plain); + + const withHint = attachCacheHintFallback({ items: [] } as unknown as Result, { ttlMs: 60_000, cacheScope: 'public' }); + const encoded = rev2025Codec.encodeResult(method, withHint); + expect(encoded).toBe(withHint); + expectNoStampedVocabulary(encoded); + } + }); + + test('a 2025-era (unclassified) tools/list exchange carries none of the stamped vocabulary', async () => { + const h = await harness({ + setup: receiver => { + receiver.setRequestHandler('tools/list', () => ({ tools: [] })); + } + }); + h.deliver({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} } as JSONRPCMessage); + await h.flush(); + expect(resultOf(h.sent[0])).toEqual({ tools: [] }); + expectNoStampedVocabulary(h.sent[0]); + }); +}); + +describe('S2 — input_required results never carry cache fields', () => { + test('an input_required resources/read result on the 2026 era is emitted without ttlMs/cacheScope', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler('resources/read', (() => ({ resultType: 'input_required', inputRequests: {} })) as never); + } + }); + h.deliver( + { jsonrpc: '2.0', id: 1, method: 'resources/read', params: { uri: 'test://a', _meta: { ...ENVELOPE } } } as JSONRPCMessage, + MODERN + ); + await h.flush(); + const result = resultOf(h.sent[0]); + expect(result?.['resultType']).toBe('input_required'); + expect(result !== undefined && 'ttlMs' in result).toBe(false); + expect(result !== undefined && 'cacheScope' in result).toBe(false); + }); +}); + +describe('S3 — non-cacheable operations are never filled', () => { + test('the cacheable-operation list is closed (six operations; call/get/complete results are excluded)', () => { + expect([...CACHEABLE_RESULT_METHODS].sort()).toEqual( + ['prompts/list', 'resources/list', 'resources/read', 'resources/templates/list', 'server/discover', 'tools/list'].sort() + ); + expect(CACHEABLE_RESULT_METHODS).not.toContain('tools/call'); + expect(CACHEABLE_RESULT_METHODS).not.toContain('prompts/get'); + expect(CACHEABLE_RESULT_METHODS).not.toContain('completion/complete'); + }); + + test('a 2026-era tools/call result is stamped but never given cache fields', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler('tools/call', () => ({ content: [] })); + } + }); + h.deliver( + { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 't', arguments: {}, _meta: { ...ENVELOPE } } } as JSONRPCMessage, + MODERN + ); + await h.flush(); + const result = resultOf(h.sent[0]); + expect(result?.['resultType']).toBe('complete'); + expect(result !== undefined && 'ttlMs' in result).toBe(false); + expect(result !== undefined && 'cacheScope' in result).toBe(false); + }); +}); + +describe('S4 — era-removed (2025-only) methods are never stamped', () => { + const LEGACY_ONLY_EMPTY_RESULT_CARRIERS = ['ping', 'logging/setLevel', 'resources/subscribe', 'resources/unsubscribe'] as const; + + test('the 2026-era registry has no entry for the 2025-only EmptyResult carriers (they can never reach the 2026 encode seam)', () => { + for (const method of [...LEGACY_ONLY_EMPTY_RESULT_CARRIERS, 'initialize']) { + expect(rev2026Codec.hasRequestMethod(method)).toBe(false); + } + }); + + test('a 2025-era ping answer (EmptyResult) carries none of the stamped vocabulary', async () => { + const h = await harness({ + setup: receiver => { + receiver.setRequestHandler('ping', () => ({})); + } + }); + h.deliver({ jsonrpc: '2.0', id: 1, method: 'ping' } as JSONRPCMessage); + await h.flush(); + expect(resultOf(h.sent[0])).toEqual({}); + expectNoStampedVocabulary(h.sent[0]); + }); + + test('a 2026-era instance answers an era-removed method with method-not-found and no stamped vocabulary', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler('ping', () => ({})); + } + }); + h.deliver({ jsonrpc: '2.0', id: 1, method: 'ping', params: { _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN); + await h.flush(); + expect(errorOf(h.sent[0])?.code).toBe(-32_601); + expectNoStampedVocabulary(h.sent[0]); + }); +}); + +describe('S5 — stamping is response-side only', () => { + test('a request emitted by a 2026-era sender carries none of the result vocabulary', async () => { + const [peerTx, senderTx] = InMemoryTransport.createLinkedPair(); + const requests: JSONRPCMessage[] = []; + peerTx.onmessage = message => { + requests.push(message); + const request = message as { id?: number | string; method?: string }; + if (request.id !== undefined && request.method === 'server/discover') { + void peerTx.send({ + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + supportedVersions: ['2026-07-28'], + capabilities: {}, + serverInfo: { name: 'peer', version: '0.0.0' } + } + } as JSONRPCMessage); + } + }; + await peerTx.start(); + + const sender = new TestProtocol(); + setNegotiatedProtocolVersion(sender, '2026-07-28'); + await sender.connect(senderTx); + + await sender.request({ method: 'server/discover' }); + + expect(requests).toHaveLength(1); + expectNoStampedVocabulary(requests[0]); + await sender.close(); + }); +}); + +describe('S6 — error responses are never stamped', () => { + test('a handler-thrown error on the 2026 era is emitted without any result vocabulary', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler('tools/list', () => { + throw Object.assign(new Error('nope'), { code: -32_602 }); + }); + } + }); + h.deliver({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN); + await h.flush(); + expect(errorOf(h.sent[0])?.code).toBe(-32_602); + expectNoStampedVocabulary(h.sent[0]); + }); +}); + +describe('the consumer-authored carve-out (documented leak note)', () => { + test('cache fields authored by a consumer handler on the 2025 era pass through unchanged — only SDK-stamped vocabulary is asserted absent', async () => { + const h = await harness({ + setup: receiver => { + receiver.setRequestHandler('tools/list', (() => ({ tools: [], ttlMs: 5_000, cacheScope: 'public' })) as never); + } + }); + h.deliver({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} } as JSONRPCMessage); + await h.flush(); + const result = resultOf(h.sent[0]); + // Pass-through, byte-for-byte what the handler authored: stripping it + // would change deployed 2025-era behavior. The negative-vocabulary + // assertions in this suite therefore target SDK-stamped values only. + expect(result).toEqual({ tools: [], ttlMs: 5_000, cacheScope: 'public' }); + expect(result !== undefined && 'resultType' in result).toBe(false); + }); +}); From b89d411650944a49a17f9db0430ffef458979c1a Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 04:16:43 +0000 Subject: [PATCH 03/14] feat(server): add cache hint configuration for cacheable 2026-era results Adds ServerOptions.cacheHints (per-operation hints for the results the SDK builds itself, including server/discover and the list operations) and an optional cacheHint member on the registerResource config (per-resource hints for resources/read). Configured hints are validated when configured (RangeError on an invalid ttlMs or cacheScope) and are attached to results on a symbol-keyed carrier that only the 2026-era encode seam reads, so cache fields returned by a handler always win and 2025-era responses never change. CacheHint/CacheScope are exported from the server package. --- packages/server/src/index.ts | 4 + packages/server/src/server/mcp.ts | 47 ++++- packages/server/src/server/server.ts | 47 ++++- .../server/test/server/cacheHints.test.ts | 197 ++++++++++++++++++ 4 files changed, 286 insertions(+), 9 deletions(-) create mode 100644 packages/server/test/server/cacheHints.test.ts diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 2a1e272d43..76244d12b3 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -71,5 +71,9 @@ export type { } from '@modelcontextprotocol/core'; export { classifyInboundRequest } from '@modelcontextprotocol/core'; +// Cache hints for cacheable 2026-07-28 results (ServerOptions.cacheHints and +// the registerResource cacheHint option). +export type { CacheHint, CacheScope } from '@modelcontextprotocol/core'; + // re-export curated public API from core export * from '@modelcontextprotocol/core/public'; diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 5e9115391d..6fcdd9a327 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -1,5 +1,6 @@ import type { BaseMetadata, + CacheHint, CallToolResult, CompleteRequestPrompt, CompleteRequestResourceTemplate, @@ -27,6 +28,8 @@ import type { import { assertCompleteRequestPrompt, assertCompleteRequestResourceTemplate, + assertValidCacheHint, + attachCacheHintFallback, normalizeRawShapeSchema, promptArgumentsFromStandardSchema, ProtocolError, @@ -413,14 +416,17 @@ export class McpServer { if (!resource.enabled) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource ${uri} disabled`); } - return resource.readCallback(uri, ctx); + // A per-resource cache hint is the most specific configured + // author for this result's 2026-07-28 cache fields; it rides a + // never-serialized carrier and is resolved at the encode seam. + return attachCacheHintFallback(await resource.readCallback(uri, ctx), resource.cacheHint); } // Then check templates for (const template of Object.values(this._registeredResourceTemplates)) { const variables = template.resourceTemplate.uriTemplate.match(uri.toString()); if (variables) { - return template.readCallback(uri, variables, ctx); + return attachCacheHintFallback(await template.readCallback(uri, variables, ctx), template.cacheHint); } } @@ -499,19 +505,36 @@ export class McpServer { * ); * ``` */ - registerResource(name: string, uriOrTemplate: string, config: ResourceMetadata, readCallback: ReadResourceCallback): RegisteredResource; + registerResource( + name: string, + uriOrTemplate: string, + config: ResourceMetadata & { cacheHint?: CacheHint }, + readCallback: ReadResourceCallback + ): RegisteredResource; registerResource( name: string, uriOrTemplate: ResourceTemplate, - config: ResourceMetadata, + config: ResourceMetadata & { cacheHint?: CacheHint }, readCallback: ReadResourceTemplateCallback ): RegisteredResourceTemplate; registerResource( name: string, uriOrTemplate: string | ResourceTemplate, - config: ResourceMetadata, + config: ResourceMetadata & { cacheHint?: CacheHint }, readCallback: ReadResourceCallback | ReadResourceTemplateCallback ): RegisteredResource | RegisteredResourceTemplate { + // The cache hint configures the encode-time cache fields of this + // resource's `resources/read` results (2026-07-28); it is not resource + // metadata and never appears on `resources/list` entries. + const cacheHint = config.cacheHint; + let metadata: ResourceMetadata = config; + if (cacheHint !== undefined) { + assertValidCacheHint(cacheHint, `resource ${name}`); + const rest = { ...config }; + delete rest.cacheHint; + metadata = rest; + } + if (typeof uriOrTemplate === 'string') { if (this._registeredResources[uriOrTemplate]) { throw new Error(`Resource ${uriOrTemplate} is already registered`); @@ -521,9 +544,12 @@ export class McpServer { name, (config as BaseMetadata).title, uriOrTemplate, - config, + metadata, readCallback as ReadResourceCallback ); + if (cacheHint !== undefined) { + registeredResource.cacheHint = cacheHint; + } this.setResourceRequestHandlers(); this.sendResourceListChanged(); @@ -537,9 +563,12 @@ export class McpServer { name, (config as BaseMetadata).title, uriOrTemplate, - config, + metadata, readCallback as ReadResourceTemplateCallback ); + if (cacheHint !== undefined) { + registeredResourceTemplate.cacheHint = cacheHint; + } this.setResourceRequestHandlers(); this.sendResourceListChanged(); @@ -1156,6 +1185,8 @@ export type RegisteredResource = { name: string; title?: string; metadata?: ResourceMetadata; + /** Cache hint applied to this resource's `resources/read` results on the 2026-07-28 revision. */ + cacheHint?: CacheHint; readCallback: ReadResourceCallback; enabled: boolean; enable(): void; @@ -1184,6 +1215,8 @@ export type RegisteredResourceTemplate = { resourceTemplate: ResourceTemplate; title?: string; metadata?: ResourceMetadata; + /** Cache hint applied to this template's `resources/read` results on the 2026-07-28 revision. */ + cacheHint?: CacheHint; readCallback: ReadResourceTemplateCallback; enabled: boolean; enable(): void; diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index fbbafb50c9..01136bcb28 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -1,5 +1,6 @@ import type { BaseContext, + CacheHint, ClientCapabilities, CreateMessageRequest, CreateMessageRequestParamsBase, @@ -35,6 +36,8 @@ import type { ToolUseContent } from '@modelcontextprotocol/core'; import { + assertValidCacheHint, + attachCacheHintFallback, codecForVersion, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, @@ -79,6 +82,27 @@ export type ServerOptions = ProtocolOptions & { * @default Runtime-selected validator (AJV-backed on Node.js, `@cfworker/json-schema`-backed on browser/workerd runtimes) */ jsonSchemaValidator?: jsonSchemaValidator; + + /** + * Cache hints for the cacheable results of the 2026-07-28 protocol + * revision (`ttlMs` / `cacheScope`), keyed by operation. The hint is used + * when the result for that operation does not provide its own cache + * fields — most useful for the list results and `server/discover`, which + * the SDK builds itself. A hint registered with an individual resource + * (`registerResource(..., { cacheHint })`) takes precedence for that + * resource's `resources/read` results. + * + * Absent hints (or omitting this option entirely) keep today's behavior: + * cacheable 2026-07-28 results are emitted with `ttlMs: 0` and + * `cacheScope: 'private'`. Responses to 2025-era requests are never + * affected. Invalid values throw a `RangeError` at construction time. + */ + cacheHints?: Partial< + Record< + 'tools/list' | 'prompts/list' | 'resources/list' | 'resources/templates/list' | 'resources/read' | 'server/discover', + CacheHint + > + >; }; /* @@ -159,6 +183,7 @@ export class Server extends Protocol { private _capabilities: ServerCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; + private _cacheHints?: ServerOptions['cacheHints']; /** * Callback for when initialization has fully completed (i.e., the client has sent an `notifications/initialized` notification). @@ -177,6 +202,17 @@ export class Server extends Protocol { this._instructions = options?.instructions; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); + // Configured cache hints fail loudly at construction time (before any + // handler registration consults them). + if (options?.cacheHints !== undefined) { + for (const [operation, hint] of Object.entries(options.cacheHints)) { + if (hint !== undefined) { + assertValidCacheHint(hint, `cacheHints['${operation}']`); + } + } + this._cacheHints = options.cacheHints; + } + this.setRequestHandler('initialize', request => this._oninitialize(request)); this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.()); @@ -266,14 +302,21 @@ export class Server extends Protocol { /** * Enforces server-side validation for `tools/call` results regardless of how the - * handler was registered. + * handler was registered, and attaches the configured per-operation cache hint + * (when one exists) so the 2026-07-28 encode seam can fill `ttlMs`/`cacheScope` + * for results that do not provide their own. The hint rides a symbol-keyed + * property that is never serialized, so 2025-era responses are unaffected. */ protected override _wrapHandler( method: string, handler: (request: JSONRPCRequest, ctx: ServerContext) => Promise ): (request: JSONRPCRequest, ctx: ServerContext) => Promise { if (method !== 'tools/call') { - return handler; + const cacheHint = (this._cacheHints as Record | undefined)?.[method]; + if (cacheHint === undefined) { + return handler; + } + return async (request, ctx) => attachCacheHintFallback(await handler(request, ctx), cacheHint); } return async (request, ctx) => { // Era-exact validation: the request and result schemas come from diff --git a/packages/server/test/server/cacheHints.test.ts b/packages/server/test/server/cacheHints.test.ts new file mode 100644 index 0000000000..15b398e4c4 --- /dev/null +++ b/packages/server/test/server/cacheHints.test.ts @@ -0,0 +1,197 @@ +/** + * The cache-hint surface for cacheable 2026-07-28 results: + * + * - `ServerOptions.cacheHints` (per-operation hints for SDK-built results), + * - `registerResource(..., { cacheHint })` (per-resource hints), + * - configuration-time validation (`RangeError`), + * - precedence: handler-returned values (when valid) over the per-resource + * hint over the per-operation hint over the defaults + * `{ ttlMs: 0, cacheScope: 'private' }`, + * - and the era boundary: 2025-era responses never gain any of it. + */ +import type { JSONRPCMessage, JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + InMemoryTransport, + 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 type { ServerOptions } from '../../src/server/server.js'; +import { installModernOnlyHandlers, 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: 'cache-hint-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +const modernRequest = (method: string, params: Record = {}): JSONRPCRequest => + ({ + jsonrpc: '2.0', + id: 1, + method, + params: { ...params, _meta: ENVELOPE } + }) as JSONRPCRequest; + +function buildMcpServer(options?: ServerOptions): McpServer { + const mcpServer = new McpServer({ name: 'cache-hint-server', version: '1.0.0' }, options); + mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] + })); + return mcpServer; +} + +async function modernResult(mcpServer: McpServer, request: JSONRPCRequest): Promise> { + setNegotiatedProtocolVersion(mcpServer.server, MODERN_REVISION); + const response = await invoke(mcpServer, request, { classification: MODERN }); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: Record }; + return body.result; +} + +describe('configuration-time validation', () => { + it('rejects a negative ttlMs in ServerOptions.cacheHints with a RangeError', () => { + expect(() => new McpServer({ name: 's', version: '1' }, { cacheHints: { 'tools/list': { ttlMs: -1 } } })).toThrowError(RangeError); + }); + + it('rejects a non-integer ttlMs and an unknown cacheScope with a RangeError', () => { + expect(() => new Server({ name: 's', version: '1' }, { cacheHints: { 'resources/read': { ttlMs: 1.5 } } })).toThrowError( + RangeError + ); + expect( + () => new Server({ name: 's', version: '1' }, { cacheHints: { 'server/discover': { cacheScope: 'shared' as never } } }) + ).toThrowError(RangeError); + }); + + it('rejects an invalid registerResource cacheHint with a RangeError', () => { + const mcpServer = buildMcpServer(); + expect(() => + mcpServer.registerResource('bad', 'test://bad', { cacheHint: { ttlMs: -5 } }, async uri => ({ + contents: [{ uri: uri.href, text: 'x' }] + })) + ).toThrowError(RangeError); + }); +}); + +describe('modern (2026-07-28) responses', () => { + it('fills the defaults when nothing is configured', async () => { + const result = await modernResult(buildMcpServer(), modernRequest('tools/list')); + expect(result).toMatchObject({ resultType: 'complete', ttlMs: 0, cacheScope: 'private' }); + }); + + it('uses the per-operation hint from ServerOptions.cacheHints for SDK-built list results', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'tools/list': { ttlMs: 60_000, cacheScope: 'public' } } }); + const result = await modernResult(mcpServer, modernRequest('tools/list')); + expect(result).toMatchObject({ resultType: 'complete', ttlMs: 60_000, cacheScope: 'public' }); + }); + + it('uses the per-operation hint for server/discover', async () => { + const server = new Server({ name: 'discover-server', version: '1.0.0' }, { cacheHints: { 'server/discover': { ttlMs: 30_000 } } }); + installModernOnlyHandlers(server, [MODERN_REVISION]); + setNegotiatedProtocolVersion(server, MODERN_REVISION); + const response = await invoke(server, modernRequest('server/discover'), { classification: MODERN }); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: Record }; + expect(body.result).toMatchObject({ resultType: 'complete', ttlMs: 30_000, cacheScope: 'private' }); + expect(Array.isArray(body.result['supportedVersions'])).toBe(true); + }); + + it('a per-resource cacheHint wins over the per-operation hint for that resource', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { ttlMs: 1_000 } } }); + mcpServer.registerResource('hinted', 'test://hinted', { cacheHint: { ttlMs: 2_000, cacheScope: 'public' } }, async uri => ({ + contents: [{ uri: uri.href, text: 'hinted' }] + })); + const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://hinted' })); + expect(result).toMatchObject({ ttlMs: 2_000, cacheScope: 'public' }); + }); + + it('the per-operation hint applies to resources registered without their own hint', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { ttlMs: 1_000 } } }); + mcpServer.registerResource('plain', 'test://plain', {}, async uri => ({ + contents: [{ uri: uri.href, text: 'plain' }] + })); + const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://plain' })); + expect(result).toMatchObject({ ttlMs: 1_000, cacheScope: 'private' }); + }); + + it('valid handler-returned cache fields win over every configured hint', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { ttlMs: 1_000 } } }); + mcpServer.registerResource('authored', 'test://authored', { cacheHint: { ttlMs: 2_000 } }, async uri => ({ + contents: [{ uri: uri.href, text: 'authored' }], + ttlMs: 3_000, + cacheScope: 'public' + })); + const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://authored' })); + expect(result).toMatchObject({ ttlMs: 3_000, cacheScope: 'public' }); + }); + + it('invalid handler-returned values fall back to the configured hint', async () => { + const mcpServer = buildMcpServer(); + mcpServer.registerResource('invalid', 'test://invalid', { cacheHint: { ttlMs: 2_000 } }, async uri => ({ + contents: [{ uri: uri.href, text: 'invalid' }], + ttlMs: -10 + })); + const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://invalid' })); + expect(result).toMatchObject({ ttlMs: 2_000, cacheScope: 'private' }); + }); + + it('never leaks the cacheHint configuration into resources/list entries', async () => { + const mcpServer = buildMcpServer(); + mcpServer.registerResource('hinted', 'test://hinted', { cacheHint: { ttlMs: 2_000 } }, async uri => ({ + contents: [{ uri: uri.href, text: 'hinted' }] + })); + const result = await modernResult(mcpServer, modernRequest('resources/list')); + const resources = result['resources'] as Array>; + expect(resources).toHaveLength(1); + expect('cacheHint' in resources[0]!).toBe(false); + }); +}); + +describe('the 2025 era is never affected', () => { + async function legacyExchange(mcpServer: McpServer, requests: JSONRPCMessage[]): Promise { + const [peerTx, serverTx] = InMemoryTransport.createLinkedPair(); + const sent: JSONRPCMessage[] = []; + peerTx.onmessage = message => void sent.push(message); + await peerTx.start(); + await mcpServer.server.connect(serverTx); + for (const request of requests) { + serverTx.onmessage?.(request); + } + await new Promise(resolve => setTimeout(resolve, 10)); + await mcpServer.close(); + return sent; + } + + it('configured cache hints never reach a 2025-era response (no resultType, ttlMs or cacheScope on the wire)', async () => { + const mcpServer = buildMcpServer({ + cacheHints: { 'tools/list': { ttlMs: 60_000, cacheScope: 'public' }, 'resources/read': { ttlMs: 1_000 } } + }); + mcpServer.registerResource('hinted', 'test://hinted', { cacheHint: { ttlMs: 2_000 } }, async uri => ({ + contents: [{ uri: uri.href, text: 'hinted' }] + })); + + const sent = await legacyExchange(mcpServer, [ + { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} } as JSONRPCMessage, + { jsonrpc: '2.0', id: 2, method: 'resources/read', params: { uri: 'test://hinted' } } as JSONRPCMessage, + { jsonrpc: '2.0', id: 3, method: 'resources/list', params: {} } as JSONRPCMessage + ]); + + expect(sent).toHaveLength(3); + for (const message of sent) { + const json = JSON.stringify(message); + expect(json).not.toContain('"resultType"'); + expect(json).not.toContain('"ttlMs"'); + expect(json).not.toContain('"cacheScope"'); + expect(json).not.toContain('"cacheHint"'); + } + }); +}); From bada4f525b51db31a9def5348080efcf2b8b010f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 04:16:43 +0000 Subject: [PATCH 04/14] feat(server): refuse requests missing a required client capability before dispatch createMcpHandler now checks each modern request against the method-keyed client capability requirement table before constructing a per-request instance: when the request's declared clientCapabilities are missing a required capability, the entry answers the typed -32003 error with data.requiredCapabilities and HTTP 400, echoing the request id. Emitting at the entry (rather than at handler time) pins the spec-mandated 400 independently of how dispatch- and handler-produced errors are mapped to HTTP statuses. No method served on the 2026-07-28 registry has a static requirement yet, so production behavior is unchanged until such methods exist. --- .../server/src/server/createMcpHandler.ts | 54 ++++++++-- .../createMcpHandlerCapabilityGate.test.ts | 98 +++++++++++++++++++ 2 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 packages/server/test/server/createMcpHandlerCapabilityGate.test.ts diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index c0da0d6219..811f14b414 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -33,8 +33,12 @@ import { classifyInboundRequest, CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, + httpStatusForErrorCode, + missingClientCapabilities, + MissingRequiredClientCapabilityError, modernOnlyStrictRejection, requestMetaOf, + requiredClientCapabilitiesForRequest, SdkError, SdkErrorCode, setNegotiatedProtocolVersion, @@ -185,12 +189,18 @@ export interface McpHttpHandler { * Shared response helpers * ------------------------------------------------------------------------ */ -function jsonRpcErrorResponse(httpStatus: number, code: number, message: string, data?: unknown): Response { +function jsonRpcErrorResponse( + httpStatus: number, + code: number, + message: string, + data?: unknown, + id: string | number | null = null +): Response { return Response.json( { jsonrpc: '2.0', error: { code, message, ...(data !== undefined && { data }) }, - id: null + id }, { status: httpStatus } ); @@ -400,6 +410,33 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa return jsonRpcErrorResponse(400, error.code, error.message, error.data); } + const meta = route.messageKind === 'request' ? requestMetaOf((message as JSONRPCRequest).params) : undefined; + const declaredClientCapabilities = meta?.[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined; + + // Pre-dispatch capability gate: a request to a method whose processing + // structurally requires a client capability the request's validated + // envelope did not declare is refused here, before any instance is + // constructed or dispatched. Answering at the entry pins the + // spec-mandated HTTP 400 for this error; a handler-time emission would + // surface in-band on HTTP 200. + if (route.messageKind === 'request') { + const required = requiredClientCapabilitiesForRequest((message as JSONRPCRequest).method); + if (required !== undefined) { + const missing = missingClientCapabilities(required, declaredClientCapabilities); + if (missing !== undefined) { + const error = new MissingRequiredClientCapabilityError({ requiredCapabilities: missing }); + reportError(error); + return jsonRpcErrorResponse( + httpStatusForErrorCode(error.code, 'ladder'), + error.code, + error.message, + error.data, + (message as JSONRPCRequest).id + ); + } + } + } + const product = await factory({ era: 'modern', ...(authInfo !== undefined && { authInfo }), @@ -412,14 +449,11 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa setNegotiatedProtocolVersion(server, claimedRevision); installModernOnlyHandlers(server, SUPPORTED_MODERN_PROTOCOL_VERSIONS); - if (route.messageKind === 'request') { - const meta = requestMetaOf((message as JSONRPCRequest).params); - if (meta !== undefined) { - seedClientIdentityFromEnvelope(server, { - clientInfo: meta[CLIENT_INFO_META_KEY] as Implementation | undefined, - clientCapabilities: meta[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined - }); - } + if (meta !== undefined) { + seedClientIdentityFromEnvelope(server, { + clientInfo: meta[CLIENT_INFO_META_KEY] as Implementation | undefined, + clientCapabilities: declaredClientCapabilities + }); } if (responseMode === 'json' && !warnedJsonModeSubscriptions && hasConfiguredSubscriptions(product)) { diff --git a/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts b/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts new file mode 100644 index 0000000000..3decb71b60 --- /dev/null +++ b/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts @@ -0,0 +1,98 @@ +/** + * The pre-dispatch client-capability gate at the HTTP entry: a request to a + * method that requires a client capability the request's envelope did not + * declare is refused with the typed `-32003` error and HTTP 400, before any + * server instance is constructed or dispatched. + * + * No request method served on the 2026-07-28 registry has a static + * requirement today, so these tests drive the gate by adding (and removing) a + * temporary entry to the requirement table; the production behavior with the + * empty table — every modern request passes the gate — is pinned too. + */ +import type { ClientCapabilities } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + REQUIRED_CLIENT_CAPABILITIES_BY_METHOD +} from '@modelcontextprotocol/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { createMcpHandler } from '../../src/server/createMcpHandler.js'; +import { McpServer } from '../../src/server/mcp.js'; + +const MODERN_REVISION = '2026-07-28'; + +const envelope = (clientCapabilities: ClientCapabilities) => ({ + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'gate-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: clientCapabilities +}); + +function postEcho(clientCapabilities: ClientCapabilities): Request { + return 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: 7, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'hi' }, _meta: envelope(clientCapabilities) } + }) + }); +} + +function factory(): McpServer { + const mcpServer = new McpServer({ name: 'gate-test-server', version: '1.0.0' }); + mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] + })); + return mcpServer; +} + +const requirementTable = REQUIRED_CLIENT_CAPABILITIES_BY_METHOD as Record; + +afterEach(() => { + delete requirementTable['tools/call']; +}); + +describe('the pre-dispatch client-capability gate', () => { + it('serves modern requests normally while no requirement applies (the table is empty in production)', async () => { + const handler = createMcpHandler(factory); + const response = await handler.fetch(postEcho({})); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('hi'); + }); + + it('refuses a request missing a required capability with -32003 and HTTP 400, echoing the request id', async () => { + requirementTable['tools/call'] = { sampling: {} }; + let factoryRan = false; + const handler = createMcpHandler(() => { + factoryRan = true; + return factory(); + }); + + const response = await handler.fetch(postEcho({ elicitation: {} })); + expect(response.status).toBe(400); + const body = (await response.json()) as { + id: unknown; + error: { code: number; data?: { requiredCapabilities?: ClientCapabilities } }; + }; + expect(body.error.code).toBe(-32_003); + expect(body.error.data?.requiredCapabilities).toEqual({ sampling: {} }); + expect(body.id).toBe(7); + // Pre-dispatch: the refusal happens before any per-request instance exists. + expect(factoryRan).toBe(false); + }); + + it('serves the request once the required capability is declared in the envelope', async () => { + requirementTable['tools/call'] = { sampling: {} }; + const handler = createMcpHandler(factory); + const response = await handler.fetch(postEcho({ sampling: {} })); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('hi'); + }); +}); From b9cd16e4166f86668cc3d508d57f4a8383a7c8ea Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 04:16:54 +0000 Subject: [PATCH 05/14] docs: document cache hints, cache-field defaults, and the typed missing-capability error Migration-guide sections and changesets for the new ServerOptions.cacheHints / registerResource cacheHint options, the always-present ttlMs/cacheScope fields on cacheable 2026-07-28 results, and MissingRequiredClientCapabilityError. --- .changeset/cacheable-result-cache-fields.md | 6 ++++ .changeset/missing-client-capability-error.md | 7 +++++ docs/migration.md | 29 +++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 .changeset/cacheable-result-cache-fields.md create mode 100644 .changeset/missing-client-capability-error.md diff --git a/.changeset/cacheable-result-cache-fields.md b/.changeset/cacheable-result-cache-fields.md new file mode 100644 index 0000000000..b65734b2d6 --- /dev/null +++ b/.changeset/cacheable-result-cache-fields.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/server': minor +--- + +Results of the cacheable 2026-07-28 operations (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, `server/discover`) now always carry the revision's required `ttlMs`/`cacheScope` fields when served on that revision, defaulting to `ttlMs: 0` / `cacheScope: 'private'`. Servers can configure the emitted values with the new `ServerOptions.cacheHints` option (per operation) and the new `cacheHint` member of the `registerResource` config (per resource); cache fields returned by a handler win over configured hints, and configured hints are validated at construction/registration time (`RangeError` on invalid values). Responses on 2025-era connections are unchanged and never carry these fields. diff --git a/.changeset/missing-client-capability-error.md b/.changeset/missing-client-capability-error.md new file mode 100644 index 0000000000..a4fc63a3e0 --- /dev/null +++ b/.changeset/missing-client-capability-error.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +Add `MissingRequiredClientCapabilityError`, the typed error class for the 2026-07-28 `-32003` protocol error (processing a request requires a capability the client did not declare). Its `data.requiredCapabilities` lists the missing capabilities and `ProtocolError.fromError` recognizes the code/data shape. The 2026-07-28 HTTP entry refuses requests that require an undeclared client capability with this error and HTTP status `400` before dispatch. diff --git a/docs/migration.md b/docs/migration.md index 6705b0b0a9..b02d939b89 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1068,6 +1068,35 @@ The entry performs no Origin/Host validation (see the origin-validation middlewa request headers. Power users who want to compose routing themselves can use the exported `classifyInboundRequest` and `PerRequestHTTPServerTransport` building blocks directly; the handler faces are bound properties, so they can be detached and passed around (`const { fetch } = handler`). +### Cache fields and cache hints for cacheable 2026-07-28 results + +The 2026-07-28 revision requires `ttlMs` and `cacheScope` on the cacheable results (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, `server/discover`). When serving that revision, the SDK now always emits both fields, +defaulting to `ttlMs: 0` and `cacheScope: 'private'` — the most conservative policy, equivalent to "do not cache". To advertise a real cache policy: + +```typescript +const server = new McpServer( + { name: 'my-server', version: '1.0.0' }, + { + capabilities: { tools: {}, resources: {} }, + // per-operation hints, used when a result does not carry its own values + cacheHints: { 'tools/list': { ttlMs: 60_000, cacheScope: 'public' } } + } +); + +// per-resource hint for that resource's resources/read results +server.registerResource('config', 'config://app', { cacheHint: { ttlMs: 5_000 } }, async uri => ({ + contents: [{ uri: uri.href, text: '…' }] +})); +``` + +Resolution is most specific first: cache fields returned by the handler itself (when valid) win over the per-resource `cacheHint`, which wins over `ServerOptions.cacheHints[operation]`, which wins over the defaults. Configured hints are validated when they are configured — +an invalid `ttlMs` (negative or non-integer) or `cacheScope` throws a `RangeError`. Responses on 2025-era connections never carry these fields, with or without configuration. + +### Typed `-32003` missing-client-capability error + +`MissingRequiredClientCapabilityError` is the typed error class for the 2026-07-28 `-32003` protocol error: processing a request requires a capability the client did not declare in the request's `clientCapabilities`. Its `data.requiredCapabilities` lists the missing +capabilities, and `ProtocolError.fromError` recognizes the code/data shape (recognize peers' errors by their code and `error.data`, not by `instanceof`). When the HTTP entry refuses such a request, the response uses HTTP status `400` as the specification requires. + ### Client identity accessors deprecated in favor of per-request context `Server.getClientCapabilities()`, `Server.getClientVersion()` and `Server.getNegotiatedProtocolVersion()` are deprecated (they remain functional). On 2026-07-28 requests the client's identity travels with each request in the validated `_meta` envelope and is available to From 2f6f01b2e09fae31ecb161ead2a765b7698541b2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 09:21:19 +0000 Subject: [PATCH 06/14] fix(core): validate ttlMs as a safe integer and correct the capability-rung metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isValidCacheTtlMs now requires a safe integer: the wire schemas validate ttlMs as an integer within the safe range, so a handler-returned value like 1e20 was emitted only to fail wire validation downstream. Such values now fall through to the configured hint / defaults like other invalid values, and the configuration-time RangeError covers them too. The validity-gate tests gain the unsafe-integer, NaN and Infinity cases. - The validation ladder's client-capabilities rung descriptor said evaluatedAt: 'dispatch' while the implemented gate runs at the HTTP entry, pre-dispatch. The descriptor now uses a dedicated 'pre-dispatch' value and its rationale qualifies the documented precedence: with the requirement table empty the order is preserved vacuously; once a served method gains a requirement entry, the entry must consult the method registry before the gate for the documented order to stay observable. Data/doc only — the gate itself is unchanged. - One JSDoc sentence on the resultType pass-through row: non-'complete' strings returned by handlers of the multi round-trip methods are forwarded verbatim, so their validity is the handler author's responsibility. --- .../core/src/shared/inboundClassification.ts | 29 ++++++++++++------- packages/core/src/shared/resultCacheHints.ts | 14 ++++++--- .../src/wire/rev2026-07-28/encodeContract.ts | 4 +++ .../core/test/wire/encodeContract.test.ts | 3 ++ 4 files changed, 36 insertions(+), 14 deletions(-) diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index 693f31a87c..c20e0c1e62 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -163,8 +163,13 @@ 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'; + /** + * Where the rung is evaluated: at the HTTP entry edge by + * {@linkcode classifyInboundRequest} (`edge`), by the HTTP entry after + * classification but before dispatch (`pre-dispatch`), or by the protocol + * layer at dispatch (`dispatch`). + */ + evaluatedAt: 'edge' | 'pre-dispatch' | '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). */ @@ -179,9 +184,11 @@ export interface InboundValidationRungDescriptor { * 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. + * gate, the envelope requiredness check, and per-method params validation). + * The client-capability rung is evaluated by the HTTP entry itself, + * pre-dispatch, on the validated envelope the classifier produced — see that + * rung's rationale for the ordering caveat. The order is the precedence: a + * request that fails several rungs is answered by the earliest one. */ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor[] = [ { @@ -245,14 +252,16 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor { rung: 'client-capabilities', order: 7, - evaluatedAt: 'dispatch', + evaluatedAt: 'pre-dispatch', codes: [ProtocolErrorCode.MissingRequiredClientCapability], conformance: ['server-stateless'], rationale: - 'Capability assertion runs after envelope validation and method resolution, immediately before the handler. The ' + - 'emission is performed by the HTTP entry before dispatch (the requirement table is method-keyed, so a request ' + - 'answered by an earlier rung never reaches it), pinning the spec-mandated HTTP 400 independently of how dispatch- ' + - 'and handler-produced errors are mapped.' + 'The capability requirement is checked by the HTTP entry, pre-dispatch, against the validated envelope the ' + + 'classifier produced — pinning the spec-mandated HTTP 400 independently of how dispatch- and handler-produced ' + + 'errors are mapped. The documented order (after method resolution and params validation) is preserved observably ' + + 'only while the requirement table is empty: once a served method gains a requirement entry, a request that is ' + + 'missing the capability and would also fail a dispatch rung is answered by this gate first, so the entry must ' + + 'consult the method registry before the gate if the documented precedence is to stay observable.' } ]; diff --git a/packages/core/src/shared/resultCacheHints.ts b/packages/core/src/shared/resultCacheHints.ts index 3461fc4849..33a38e95aa 100644 --- a/packages/core/src/shared/resultCacheHints.ts +++ b/packages/core/src/shared/resultCacheHints.ts @@ -30,7 +30,7 @@ export type CacheScope = 'public' | 'private'; * (`ttlMs: 0`, `cacheScope: 'private'`). */ export interface CacheHint { - /** Cache lifetime in milliseconds. Must be a non-negative integer. */ + /** Cache lifetime in milliseconds. Must be a non-negative safe integer. */ ttlMs?: number; /** Whether the result may be cached by shared caches (`public`) or only by the requesting client (`private`). */ cacheScope?: CacheScope; @@ -92,9 +92,15 @@ export function cacheHintFallbackOf(result: object): CacheHint | undefined { return (result as CacheHintCarrier)[RESULT_CACHE_HINT_FALLBACK]; } -/** Whether a value is a valid `ttlMs`: a non-negative, finite integer. */ +/** + * Whether a value is a valid `ttlMs`: a non-negative safe integer. Safe + * integers are required because the wire schemas validate `ttlMs` as an + * integer within `Number.MIN_SAFE_INTEGER`/`Number.MAX_SAFE_INTEGER`; a value + * outside that range is treated as invalid here so it falls through to the + * next author instead of being emitted and rejected downstream. + */ export function isValidCacheTtlMs(value: unknown): value is number { - return typeof value === 'number' && Number.isInteger(value) && value >= 0; + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; } /** Whether a value is a valid `cacheScope`. */ @@ -109,7 +115,7 @@ export function isValidCacheScope(value: unknown): value is CacheScope { */ export function assertValidCacheHint(hint: CacheHint, context: string): void { if (hint.ttlMs !== undefined && !isValidCacheTtlMs(hint.ttlMs)) { - throw new RangeError(`Invalid cache hint for ${context}: ttlMs must be a non-negative integer (got ${String(hint.ttlMs)})`); + throw new RangeError(`Invalid cache hint for ${context}: ttlMs must be a non-negative safe integer (got ${String(hint.ttlMs)})`); } if (hint.cacheScope !== undefined && !isValidCacheScope(hint.cacheScope)) { throw new RangeError( diff --git a/packages/core/src/wire/rev2026-07-28/encodeContract.ts b/packages/core/src/wire/rev2026-07-28/encodeContract.ts index 36f12a989a..d03613aeeb 100644 --- a/packages/core/src/wire/rev2026-07-28/encodeContract.ts +++ b/packages/core/src/wire/rev2026-07-28/encodeContract.ts @@ -55,6 +55,10 @@ export const EXTENDED_RESULT_TYPE_METHODS: readonly string[] = ['tools/call', 'p * - Handler-provided `'complete'` → kept as-is. * - Handler-provided non-`'complete'` value on a method whose vocabulary * allows it ({@linkcode EXTENDED_RESULT_TYPE_METHODS}) → passes through. + * The value is forwarded verbatim — the wire vocabulary is an open union and + * the SDK does not validate the string, so emitting a `resultType` the + * negotiated revision does not define is the handler author's + * responsibility. * - Handler-provided non-`'complete'` value on any other method → internal * error (loud): the value would be mis-typed on the wire, and silently * rewriting it would hide a server bug. diff --git a/packages/core/test/wire/encodeContract.test.ts b/packages/core/test/wire/encodeContract.test.ts index 53dcaffd2e..6a3f1071eb 100644 --- a/packages/core/test/wire/encodeContract.test.ts +++ b/packages/core/test/wire/encodeContract.test.ts @@ -135,6 +135,9 @@ describe('step 2 — the cache fill', () => { test.each([ ['a negative ttlMs', { ttlMs: -1 }], ['a non-integer ttlMs', { ttlMs: 1.5 }], + ['an unsafe-integer ttlMs (above 2^53 - 1, rejected by the wire schemas)', { ttlMs: 1e20 }], + ['a NaN ttlMs', { ttlMs: Number.NaN }], + ['an infinite ttlMs', { ttlMs: Number.POSITIVE_INFINITY }], ['a non-numeric ttlMs', { ttlMs: 'soon' }], ['an unknown cacheScope', { cacheScope: 'shared' }] ])('invalid handler-returned values (%s) never reach the wire — the next author wins', (_label, invalid) => { From 3853d80ec8cf91840278590448267b1f88122513 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 09:21:35 +0000 Subject: [PATCH 07/14] fix(server): key ServerOptions.cacheHints by the cacheable-method type - ServerOptions.cacheHints re-spelled the six cacheable operations as a string-literal union; it is now keyed by CacheableResultMethod so the closed operation list has a single source of truth (the option's JSDoc still names the operations). No new exports; the accepted keys are unchanged. - Add per-operation cache-hint coverage for prompts/list, resources/list and resources/templates/list, completing the op-level surface (tools/list, resources/read and server/discover were already covered). - Changeset wording: note that registerResource now interprets a cacheHint key in its config object (observable for untyped callers), and clarify that the -32003 pre-dispatch gate changes no behavior until a served method actually carries a capability requirement. --- .changeset/cacheable-result-cache-fields.md | 2 +- .changeset/missing-client-capability-error.md | 2 +- packages/server/src/server/server.ts | 22 ++++++------- .../server/test/server/cacheHints.test.ts | 32 ++++++++++++++++++- 4 files changed, 43 insertions(+), 15 deletions(-) diff --git a/.changeset/cacheable-result-cache-fields.md b/.changeset/cacheable-result-cache-fields.md index b65734b2d6..51ddf16247 100644 --- a/.changeset/cacheable-result-cache-fields.md +++ b/.changeset/cacheable-result-cache-fields.md @@ -3,4 +3,4 @@ '@modelcontextprotocol/server': minor --- -Results of the cacheable 2026-07-28 operations (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, `server/discover`) now always carry the revision's required `ttlMs`/`cacheScope` fields when served on that revision, defaulting to `ttlMs: 0` / `cacheScope: 'private'`. Servers can configure the emitted values with the new `ServerOptions.cacheHints` option (per operation) and the new `cacheHint` member of the `registerResource` config (per resource); cache fields returned by a handler win over configured hints, and configured hints are validated at construction/registration time (`RangeError` on invalid values). Responses on 2025-era connections are unchanged and never carry these fields. +Results of the cacheable 2026-07-28 operations (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, `server/discover`) now always carry the revision's required `ttlMs`/`cacheScope` fields when served on that revision, defaulting to `ttlMs: 0` / `cacheScope: 'private'`. Servers can configure the emitted values with the new `ServerOptions.cacheHints` option (per operation) and the new `cacheHint` member of the `registerResource` config (per resource); cache fields returned by a handler win over configured hints, and configured hints are validated at construction/registration time (`RangeError` on invalid values). Responses on 2025-era connections are unchanged and never carry these fields. Note for untyped callers: `registerResource` now interprets a `cacheHint` key in its config object — it is validated and kept out of the resource's list metadata, where it was previously passed through as ordinary metadata. diff --git a/.changeset/missing-client-capability-error.md b/.changeset/missing-client-capability-error.md index a4fc63a3e0..9653679e05 100644 --- a/.changeset/missing-client-capability-error.md +++ b/.changeset/missing-client-capability-error.md @@ -4,4 +4,4 @@ '@modelcontextprotocol/server': minor --- -Add `MissingRequiredClientCapabilityError`, the typed error class for the 2026-07-28 `-32003` protocol error (processing a request requires a capability the client did not declare). Its `data.requiredCapabilities` lists the missing capabilities and `ProtocolError.fromError` recognizes the code/data shape. The 2026-07-28 HTTP entry refuses requests that require an undeclared client capability with this error and HTTP status `400` before dispatch. +Add `MissingRequiredClientCapabilityError`, the typed error class for the 2026-07-28 `-32003` protocol error (processing a request requires a capability the client did not declare). Its `data.requiredCapabilities` lists the missing capabilities and `ProtocolError.fromError` recognizes the code/data shape. The 2026-07-28 HTTP entry gains a pre-dispatch gate that refuses a request requiring an undeclared client capability with this error and HTTP status `400`; no method served on the 2026-07-28 registry currently carries such a requirement, so observable behavior is unchanged until methods with capability requirements exist. diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 01136bcb28..2a11d34527 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -1,5 +1,6 @@ import type { BaseContext, + CacheableResultMethod, CacheHint, ClientCapabilities, CreateMessageRequest, @@ -85,24 +86,21 @@ export type ServerOptions = ProtocolOptions & { /** * Cache hints for the cacheable results of the 2026-07-28 protocol - * revision (`ttlMs` / `cacheScope`), keyed by operation. The hint is used - * when the result for that operation does not provide its own cache - * fields — most useful for the list results and `server/discover`, which - * the SDK builds itself. A hint registered with an individual resource - * (`registerResource(..., { cacheHint })`) takes precedence for that - * resource's `resources/read` results. + * revision (`ttlMs` / `cacheScope`), keyed by operation. The cacheable + * operations are `tools/list`, `prompts/list`, `resources/list`, + * `resources/templates/list`, `resources/read` and `server/discover`. The + * hint is used when the result for that operation does not provide its own + * cache fields — most useful for the list results and `server/discover`, + * which the SDK builds itself. A hint registered with an individual + * resource (`registerResource(..., { cacheHint })`) takes precedence for + * that resource's `resources/read` results. * * Absent hints (or omitting this option entirely) keep today's behavior: * cacheable 2026-07-28 results are emitted with `ttlMs: 0` and * `cacheScope: 'private'`. Responses to 2025-era requests are never * affected. Invalid values throw a `RangeError` at construction time. */ - cacheHints?: Partial< - Record< - 'tools/list' | 'prompts/list' | 'resources/list' | 'resources/templates/list' | 'resources/read' | 'server/discover', - CacheHint - > - >; + cacheHints?: Partial>; }; /* diff --git a/packages/server/test/server/cacheHints.test.ts b/packages/server/test/server/cacheHints.test.ts index 15b398e4c4..727d63c17e 100644 --- a/packages/server/test/server/cacheHints.test.ts +++ b/packages/server/test/server/cacheHints.test.ts @@ -21,7 +21,7 @@ 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 { McpServer, ResourceTemplate } from '../../src/server/mcp.js'; import type { ServerOptions } from '../../src/server/server.js'; import { installModernOnlyHandlers, Server } from '../../src/server/server.js'; @@ -105,6 +105,36 @@ describe('modern (2026-07-28) responses', () => { expect(Array.isArray(body.result['supportedVersions'])).toBe(true); }); + it('uses the per-operation hint for prompts/list', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'prompts/list': { ttlMs: 15_000, cacheScope: 'public' } } }); + mcpServer.registerPrompt('greeting', { description: 'Say hello' }, async () => ({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }] + })); + const result = await modernResult(mcpServer, modernRequest('prompts/list')); + expect(result).toMatchObject({ resultType: 'complete', ttlMs: 15_000, cacheScope: 'public' }); + }); + + it('uses the per-operation hint for resources/list', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'resources/list': { ttlMs: 20_000 } } }); + mcpServer.registerResource('plain', 'test://plain', {}, async uri => ({ + contents: [{ uri: uri.href, text: 'plain' }] + })); + const result = await modernResult(mcpServer, modernRequest('resources/list')); + expect(result).toMatchObject({ resultType: 'complete', ttlMs: 20_000, cacheScope: 'private' }); + }); + + it('uses the per-operation hint for resources/templates/list', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'resources/templates/list': { ttlMs: 45_000, cacheScope: 'public' } } }); + mcpServer.registerResource( + 'templated', + new ResourceTemplate('test://things/{id}', { list: undefined }), + {}, + async (uri, { id }) => ({ contents: [{ uri: uri.href, text: `id=${String(id)}` }] }) + ); + const result = await modernResult(mcpServer, modernRequest('resources/templates/list')); + expect(result).toMatchObject({ resultType: 'complete', ttlMs: 45_000, cacheScope: 'public' }); + }); + it('a per-resource cacheHint wins over the per-operation hint for that resource', async () => { const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { ttlMs: 1_000 } } }); mcpServer.registerResource('hinted', 'test://hinted', { cacheHint: { ttlMs: 2_000, cacheScope: 'public' } }, async uri => ({ From 8538b46ce8ddaebcfe46e755df5ca2fec1aa32de Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 11:17:41 +0000 Subject: [PATCH 08/14] test(integration): run the dual-era examples as real child processes Executes examples/client/src/dualEraStdioClient.ts via tsx and asserts both legs (2025-11-25 via initialize, 2026-07-28 via server/discover) greet and the program exits 0, and runs examples/server/src/dualEraStreamableHttp.ts once per MCP_LEGACY_MODE value (none / stateless / byo), probing it over real HTTP with a 2025-shaped initialize, server/discover, and an enveloped tools/call. The example's hard-coded port 3000 is serialized on and stale listeners are cleared before each spawn; spawned children are stopped by PID and asserted to exit cleanly. The examples resolve workspace packages through their dist entry points, so the suite needs pnpm build:all to have run; failures caused by the server package being repacked (and its dist transiently rewritten) by cloudflareWorkers.test.ts during a full integration run are retried a bounded number of times. --- .../test/examples/dualEraExamples.test.ts | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 test/integration/test/examples/dualEraExamples.test.ts diff --git a/test/integration/test/examples/dualEraExamples.test.ts b/test/integration/test/examples/dualEraExamples.test.ts new file mode 100644 index 0000000000..c5729f9155 --- /dev/null +++ b/test/integration/test/examples/dualEraExamples.test.ts @@ -0,0 +1,261 @@ +/** + * Smoke coverage for the dual-era example programs, executed as real child + * processes (the way a reader of the docs would run them): + * + * - `examples/client/src/dualEraStdioClient.ts` is run via tsx and must drive + * both legs against the stdio server example it spawns itself: leg 1 + * negotiates 2025-11-25 over `initialize`, leg 2 negotiates 2026-07-28 over + * `server/discover`, both greet calls succeed, and the program exits 0. + * - `examples/server/src/dualEraStreamableHttp.ts` is run via tsx once per + * `MCP_LEGACY_MODE` value (none / stateless / byo) and probed over real + * HTTP: a 2025-shaped `initialize` (served on the legacy slot, rejected on + * the strict endpoint), `server/discover`, and a `tools/call` carrying the + * 2026 per-request `_meta` envelope. + * + * The HTTP example listens on a hard-coded port (3000) with no override knob, + * so these tests serialize on that port: stale listeners are cleared before + * each spawn (listeners only — `lsof -sTCP:LISTEN`, never a bare kill by + * port), and the spawned child is always stopped by PID. + * + * The examples resolve the workspace packages through their published dist + * entry points, so the suite requires `pnpm build:all` to have run. During a + * full integration run, cloudflareWorkers.test.ts repacks the server package + * (`pnpm pack` → prepack → build), which transiently rewrites + * packages/server/dist; an example spawned inside that window dies with a + * missing-dist module error. Such failures are retried a bounded number of + * times so only genuine example breakage fails the suite. + */ +import type { ChildProcess } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; + +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const REPO_ROOT = path.resolve(__dirname, '../../../..'); +const STDIO_CLIENT_EXAMPLE = path.join(REPO_ROOT, 'examples/client/src/dualEraStdioClient.ts'); +const HTTP_SERVER_EXAMPLE = path.join(REPO_ROOT, 'examples/server/src/dualEraStreamableHttp.ts'); + +const LEGACY = '2025-11-25'; +const MODERN = '2026-07-28'; + +/** The HTTP example's hard-coded listen port (it exposes no override). */ +const EXAMPLE_PORT = 3000; +const EXAMPLE_URL = `http://localhost:${EXAMPLE_PORT}/mcp`; + +/** Failure signature of a workspace dist being rewritten underneath a spawned example (see the file header). */ +const MISSING_DIST_PATTERN = /(ERR_MODULE_NOT_FOUND|ENOENT)[\s\S]*[/\\]dist[/\\]/; +const MAX_ATTEMPTS = 3; + +interface RunningExample { + child: ChildProcess; + exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>; + stdout: () => string; + stderr: () => string; +} + +describe('dual-era examples run as real programs', () => { + vi.setConfig({ testTimeout: 120_000 }); + + const activeChildren: ChildProcess[] = []; + + afterEach(() => { + // Belt and braces: never let a spawned example outlive its test. + for (const child of activeChildren.splice(0)) { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + } + } + }); + + function spawnExample(scriptPath: string, env: Record = {}): RunningExample { + const child = spawn(process.execPath, ['--import', 'tsx', scriptPath], { + cwd: REPO_ROOT, + env: { ...process.env, ...env }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + activeChildren.push(child); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', chunk => { + stdout += String(chunk); + }); + child.stderr?.on('data', chunk => { + stderr += String(chunk); + }); + const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(resolve => { + child.on('exit', (code, signal) => resolve({ code, signal })); + }); + return { child, exited, stdout: () => stdout, stderr: () => stderr }; + } + + it('dualEraStdioClient drives both legs against the stdio server example and exits cleanly', async () => { + let example = spawnExample(STDIO_CLIENT_EXAMPLE); + let { code, signal } = await example.exited; + for (let attempt = 1; attempt < MAX_ATTEMPTS && code !== 0 && MISSING_DIST_PATTERN.test(example.stderr()); attempt++) { + // A workspace dist was being rewritten underneath the example; wait it out and rerun. + await delay(10_000); + example = spawnExample(STDIO_CLIENT_EXAMPLE); + ({ code, signal } = await example.exited); + } + const stdout = example.stdout(); + + expect(code, `expected exit 0\nstdout:\n${stdout}\nstderr:\n${example.stderr()}`).toBe(0); + expect(signal).toBeNull(); + + // Split the transcript at the leg-2 marker so each negotiation and + // greeting is asserted against its own leg. + const legBoundary = stdout.indexOf('--- leg 2'); + expect(legBoundary, `expected the leg-2 marker in stdout:\n${stdout}`).toBeGreaterThan(-1); + const leg1 = stdout.slice(0, legBoundary); + const leg2 = stdout.slice(legBoundary); + + expect(leg1).toContain('--- leg 1'); + expect(leg1).toContain(`negotiated protocol version: ${LEGACY}`); + expect(leg1).toContain('Hello, 2025 client!'); + + expect(leg2).toContain(`negotiated protocol version: ${MODERN}`); + expect(leg2).toContain('Hello, 2026 client!'); + + expect(stdout).toContain('both legs served by the same dual-era stdio server.'); + }); + + // ── HTTP example (one spawn per MCP_LEGACY_MODE value) ────────────────── + + /** Kills stale listeners on the example's hard-coded port (LISTEN sockets only — never a bare kill by port). */ + async function clearStalePortListeners(): Promise { + await new Promise(resolve => { + const cleanup = spawn('sh', ['-c', `lsof -ti:${EXAMPLE_PORT} -sTCP:LISTEN | xargs -r kill`], { stdio: 'ignore' }); + cleanup.on('exit', () => resolve()); + cleanup.on('error', () => resolve()); + }); + } + + async function startHttpExample(mode: 'none' | 'stateless' | 'byo'): Promise { + for (let attempt = 1; ; attempt++) { + await clearStalePortListeners(); + const example = spawnExample(HTTP_SERVER_EXAMPLE, { MCP_LEGACY_MODE: mode }); + // Wait for the listening line, but stop waiting as soon as the child exits. + let exited = false; + void example.exited.then(() => { + exited = true; + }); + await vi.waitFor( + () => { + if (!example.stdout().includes('Dual-era MCP server listening') && !exited) throw new Error('not listening yet'); + }, + { timeout: 60_000, interval: 100 } + ); + if (example.stdout().includes('Dual-era MCP server listening')) { + expect(example.stdout()).toContain(`legacy mode: ${mode}`); + return example; + } + if (attempt < MAX_ATTEMPTS && MISSING_DIST_PATTERN.test(example.stderr())) { + // A workspace dist was being rewritten underneath the example; wait it out and respawn. + await delay(10_000); + continue; + } + throw new Error(`example exited before listening\nstdout:\n${example.stdout()}\nstderr:\n${example.stderr()}`); + } + } + + /** Stops the spawned example by PID and asserts it shuts down cleanly (the example handles SIGINT itself). */ + async function stopHttpExample(example: RunningExample): Promise { + if (example.child.exitCode === null && example.child.signalCode === null) { + example.child.kill('SIGINT'); + } + const { code, signal } = await example.exited; + expect(signal).toBeNull(); + expect(code).toBe(0); + } + + function modernEnvelope() { + return { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'examples-smoke-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; + } + + const legacyInitialize = { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: LEGACY, capabilities: {}, clientInfo: { name: 'legacy-probe', version: '1.0.0' } } + }; + + /** POSTs one JSON-RPC message and returns the HTTP status plus the parsed JSON-RPC response (JSON or SSE body). */ + async function postJsonRpc( + message: Record + ): Promise<{ status: number; message: Record | undefined }> { + const response = await fetch(EXAMPLE_URL, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(message) + }); + const text = await response.text(); + const contentType = response.headers.get('content-type') ?? ''; + let parsed: Record | undefined; + if (contentType.includes('text/event-stream')) { + parsed = text + .split('\n') + .filter(line => line.startsWith('data: ')) + .map(line => JSON.parse(line.slice(6)) as Record) + .find(candidate => 'result' in candidate || 'error' in candidate); + } else if (text !== '') { + parsed = JSON.parse(text) as Record; + } + return { status: response.status, message: parsed }; + } + + /** The 2026-07-28 path is identical in every slot state: discover advertises it and an enveloped tools/call is served. */ + async function probeModernPath(): Promise { + const discover = await postJsonRpc({ jsonrpc: '2.0', id: 2, method: 'server/discover', params: { _meta: modernEnvelope() } }); + expect(discover.status).toBe(200); + const discoverResult = (discover.message as { result?: { supportedVersions?: string[]; serverInfo?: { name?: string } } }).result; + expect(discoverResult?.supportedVersions).toContain(MODERN); + expect(discoverResult?.serverInfo?.name).toBe('dual-era-server'); + + const call = await postJsonRpc({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'greet', arguments: { name: 'smoke probe' }, _meta: modernEnvelope() } + }); + expect(call.status).toBe(200); + const callResult = (call.message as { result?: { resultType?: string; content?: unknown } }).result; + expect(callResult?.resultType).toBe('complete'); + expect(callResult?.content).toEqual([{ type: 'text', text: 'Hello, smoke probe! (served on the modern protocol era)' }]); + } + + it.each(['stateless', 'byo'] as const)('dualEraStreamableHttp with MCP_LEGACY_MODE=%s serves both eras over real HTTP', async mode => { + const example = await startHttpExample(mode); + try { + const init = await postJsonRpc(legacyInitialize); + expect(init.status).toBe(200); + const initResult = (init.message as { result?: { protocolVersion?: string; serverInfo?: { name?: string } } }).result; + expect(initResult?.protocolVersion).toBe(LEGACY); + expect(initResult?.serverInfo?.name).toBe('dual-era-server'); + + await probeModernPath(); + } finally { + await stopHttpExample(example); + } + }); + + it('dualEraStreamableHttp with MCP_LEGACY_MODE=none rejects 2025-shaped initialize and still serves the modern path', async () => { + const example = await startHttpExample('none'); + try { + const init = await postJsonRpc(legacyInitialize); + expect(init.status).toBe(400); + const error = (init.message as { error?: { message?: string; data?: { supported?: string[] } } }).error; + expect(error?.message).toMatch(/unsupported protocol version/i); + expect(error?.data?.supported).toContain(MODERN); + + await probeModernPath(); + } finally { + await stopHttpExample(example); + } + }); +}); From baff0e1b4b4f2408dea20dbf21fb441db20d1643 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 11:18:17 +0000 Subject: [PATCH 09/14] test(e2e): add dual-era entry and stdio serving scenarios to the version matrix New requirement rows and scenario bodies for the dual-era serving stack: - hosting-entry.test.ts hosts createMcpHandler's node face on a real node:http listener and drives one ctx-taking factory per the spec-version axis: a plain client through the legacy 'stateless' slot on 2025-11-25 and the auto-negotiating client (plus a pin arm) on 2026-07-28, the strict (legacy-omitted) rejection of a 2025-shaped initialize with the supported list, and notification POSTs answering 202 on both legs. - stdio-dual-era.test.ts spawns a dual-era (eraSupport: 'dual-era') stdio fixture as a real child process and drives it with a plain 2025 client or the auto-negotiating client per the spec-version axis. The new requirements are transport-restricted (with notes) so each registers one cell per applicable spec version; no existing requirement or scenario changes. --- test/e2e/fixtures/dual-era-stdio-server.ts | 29 ++++ test/e2e/requirements.ts | 40 +++++ test/e2e/scenarios/hosting-entry.test.ts | 186 +++++++++++++++++++++ test/e2e/scenarios/stdio-dual-era.test.ts | 88 ++++++++++ 4 files changed, 343 insertions(+) create mode 100644 test/e2e/fixtures/dual-era-stdio-server.ts create mode 100644 test/e2e/scenarios/hosting-entry.test.ts create mode 100644 test/e2e/scenarios/stdio-dual-era.test.ts diff --git a/test/e2e/fixtures/dual-era-stdio-server.ts b/test/e2e/fixtures/dual-era-stdio-server.ts new file mode 100644 index 0000000000..b99b9763a9 --- /dev/null +++ b/test/e2e/fixtures/dual-era-stdio-server.ts @@ -0,0 +1,29 @@ +/** + * Runnable dual-era stdio MCP server fixture for the dual-era stdio e2e cells. + * + * `eraSupport: 'dual-era'` is the single declared act on an otherwise ordinary + * hand-constructed McpServer connected to the unchanged StdioServerTransport. + * Spawned as a real child process (via tsx) by + * test/e2e/scenarios/stdio-dual-era.test.ts; exits when its stdin reaches EOF. + */ + +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { z } from 'zod/v4'; + +const server = new McpServer( + { name: 'dual-era-stdio-e2e-fixture', version: '1.0.0' }, + { capabilities: { tools: {} }, eraSupport: 'dual-era' } +); + +server.registerTool( + 'echo', + { + description: 'Echoes the input text back as a text content block.', + inputSchema: z.object({ text: z.string() }) + }, + ({ text }) => ({ content: [{ type: 'text', text }] }) +); + +await server.connect(new StdioServerTransport()); +process.stderr.write('[dual-era-stdio-server] ready\n'); diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 7f5d68077e..cb4a2d9417 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2195,6 +2195,46 @@ export const REQUIREMENTS: Record = { transports: ['streamableHttp'], note: 'This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. The allowed-host control asserts initialize semantics per spec version: a 2026-era request is answered with the latest legacy version, since 2026-era revisions are never negotiated via initialize.' }, + + // v2 features: dual-era serving (createMcpHandler entry, eraSupport stdio, result stamping) + + 'typescript:hosting:entry:dual-era-one-factory': { + source: 'sdk', + behavior: + 'createMcpHandler serves one ctx-taking factory to both protocol eras on one endpoint: with the legacy "stateless" slot configured, a plain client is served per request via initialize, tools/list and tools/call on the 2025 era, and an auto-negotiating client reaches 2026-07-28 via server/discover (never initialize) and gets tools/call served with the per-request _meta envelope.', + transports: ['streamableHttp'], + note: 'The entry is an HTTP-only surface; each cell hosts the handler node face on a real node:http listener (the matrix transport arg is ignored, fixed to a single streamableHttp-labelled cell) and the spec-version axis selects which era the cell drives.' + }, + 'typescript:hosting:entry:pin-negotiation': { + source: 'sdk', + behavior: + 'A client pinned to the 2026-07-28 revision (versionNegotiation mode pin) connects to a strict createMcpHandler endpoint without ever sending initialize — its first request is server/discover — and an enveloped tools/call round-trips.', + transports: ['streamableHttp'], + addedInSpecVersion: '2026-07-28', + note: 'The entry is an HTTP-only surface; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and the requirement is fixed to a single streamableHttp-labelled cell on the 2026-07-28 axis.' + }, + 'typescript:hosting:entry:strict-rejects-legacy': { + source: 'sdk', + behavior: + 'A createMcpHandler endpoint with no legacy slot configured (modern-only strict) rejects a 2025-shaped initialize with the unsupported-protocol-version error carrying the supported modern revisions in error.data.supported; nothing is silently served on the 2025 era.', + transports: ['streamableHttp'], + addedInSpecVersion: '2026-07-28', + note: 'The entry is an HTTP-only surface; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and the requirement is fixed to a single streamableHttp-labelled cell on the 2026-07-28 axis. The numeric error code is asserted by message and supported-list shape only, since it shares a code with the still-disputed header/body mismatch family.' + }, + 'typescript:hosting:entry:notification-202': { + source: 'sdk', + behavior: + 'A POST carrying only a notification is answered 202 Accepted with an empty body by a createMcpHandler endpoint on both legs: an envelope-less notification through the legacy stateless slot and an envelope-carrying notification on the modern path.', + transports: ['streamableHttp'], + note: 'The entry is an HTTP-only surface; each cell hosts the handler node face on a real node:http listener (the matrix transport arg is ignored, fixed to a single streamableHttp-labelled cell) and the spec-version axis selects which leg the notification rides.' + }, + 'typescript:transport:stdio:dual-era-serving': { + source: 'sdk', + behavior: + 'A hand-constructed stdio server declaring eraSupport "dual-era" (transport line unchanged) serves a plain 2025 client via initialize and an auto-negotiating client on 2026-07-28 via server/discover, over a real child-process pipe.', + transports: ['stdio'], + note: 'Dual-era stdio serving is exercised against a real spawned child process (fixtures/dual-era-stdio-server.ts), so the matrix transport arg is ignored and the requirement lists stdio only; the spec-version axis selects which client drives the cell.' + }, 'custom-methods:server-handler:roundtrip': { source: 'sdk', behavior: diff --git a/test/e2e/scenarios/hosting-entry.test.ts b/test/e2e/scenarios/hosting-entry.test.ts new file mode 100644 index 0000000000..3a7d397855 --- /dev/null +++ b/test/e2e/scenarios/hosting-entry.test.ts @@ -0,0 +1,186 @@ +/** + * Self-contained test bodies for the dual-era HTTP entry (`createMcpHandler`). + * + * Unlike most scenario areas these do not use `wire()`: every body hosts the + * handler's `node` face on a real `node:http` listener (the same wiring as + * `test/integration/test/server/createMcpHandler.test.ts`) and drives it with + * real SDK clients or plain fetch. The requirements therefore restrict the + * matrix transport axis to a single HTTP transport, and the spec-version axis + * selects which era a cell drives where the requirement spans both. + */ +import type { Server as HttpServer } from 'node:http'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { + CallToolResult, + CreateMcpHandlerOptions, + McpHttpHandler, + McpRequestContext, + McpServerFactory +} from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { expect } from 'vitest'; +import { z } from 'zod/v4'; + +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const LEGACY = '2025-11-25'; +const MODERN = '2026-07-28'; + +/** The per-request `_meta` envelope every 2026-era request carries (attached explicitly until automatic emission lands client-side). */ +function modernEnvelope(name = 'e2e-entry-client') { + return { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name, version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; +} + +/** One ctx-taking factory backing every cell: the era only shows up in the tool output so tests can see which leg served the call. */ +function greetFactory(ctx: McpRequestContext): McpServer { + const server = new McpServer({ name: 'e2e-entry', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name} (${ctx.era})` }] + })); + return server; +} + +interface Endpoint extends AsyncDisposable { + baseUrl: URL; + handler: McpHttpHandler; +} + +/** Hosts the handler's node face on a real node:http listener bound to an ephemeral port. */ +async function startEndpoint(factory: McpServerFactory, options?: CreateMcpHandlerOptions): Promise { + const handler = createMcpHandler(factory, options); + const httpServer: HttpServer = createServer((req, res) => void handler.node(req, res)); + const baseUrl = await listenOnRandomPort(httpServer); + return { + baseUrl, + handler, + [Symbol.asyncDispose]: async () => { + await handler.close(); + await new Promise((resolve, reject) => httpServer.close(error => (error ? reject(error) : resolve()))); + } + }; +} + +verifies('typescript:hosting:entry:dual-era-one-factory', async ({ protocolVersion }: TestArgs) => { + await using endpoint = await startEndpoint(greetFactory, { legacy: 'stateless' }); + + if (protocolVersion === LEGACY) { + // 2025-era leg: a plain client is served per request through the + // legacy 'stateless' slot — initialize → tools/list → tools/call. + const client = new Client({ name: 'plain-2025-client', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl)); + try { + expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY); + const tools = await client.listTools(); + expect(tools.tools.map(tool => tool.name)).toEqual(['greet']); + const result = await client.callTool({ name: 'greet', arguments: { name: 'old friend' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hello old friend (legacy)' }]); + } finally { + await client.close(); + } + return; + } + + // 2026-era leg: the auto-negotiating client reaches 2026-07-28 (via + // server/discover) and tools/call is served with the per-request envelope. + const client = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl)); + try { + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + const result = (await client.request({ + method: 'tools/call', + params: { name: 'greet', arguments: { name: 'new friend' }, _meta: modernEnvelope('auto-client') } + })) as CallToolResult; + expect(result.content).toEqual([{ type: 'text', text: 'hello new friend (modern)' }]); + } finally { + await client.close(); + } +}); + +verifies('typescript:hosting:entry:pin-negotiation', async (_args: TestArgs) => { + // Strict endpoint (no legacy slot): the pinned client never needs one. + await using endpoint = await startEndpoint(greetFactory); + + const bodies: string[] = []; + const recordingFetch: typeof fetch = async (input, init) => { + if (typeof init?.body === 'string') bodies.push(init.body); + return fetch(input, init); + }; + + const client = new Client({ name: 'pin-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl, { fetch: recordingFetch })); + try { + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + // No initialize was ever put on the wire; the first request is the discover probe. + expect(bodies.some(body => body.includes('"initialize"'))).toBe(false); + expect(bodies[0]).toContain('server/discover'); + + const result = (await client.request({ + method: 'tools/call', + params: { name: 'greet', arguments: { name: 'pinned' }, _meta: modernEnvelope('pin-client') } + })) as CallToolResult; + expect(result.content).toEqual([{ type: 'text', text: 'hello pinned (modern)' }]); + } finally { + await client.close(); + } +}); + +verifies('typescript:hosting:entry:strict-rejects-legacy', async (_args: TestArgs) => { + // legacy omitted → modern-only strict: no silent 2025 serving. + await using endpoint = await startEndpoint(greetFactory); + + // The documented strict cell over plain HTTP: a 2025-shaped initialize is + // answered with the unsupported-protocol-version error naming the + // supported modern revisions (the numeric code is not pinned here). + const response = await fetch(endpoint.baseUrl, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: LEGACY, capabilities: {}, clientInfo: { name: 'plain-2025-client', version: '1.0.0' } } + }) + }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: number; message: string; data?: { supported?: string[] } } }; + expect(body.error.message).toMatch(/unsupported protocol version/i); + expect(body.error.data?.supported).toContain(MODERN); + + // The plain SDK client sees the same rejection at connect time. + const client = new Client({ name: 'plain-2025-client', version: '1.0.0' }); + await expect(client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl))).rejects.toThrow(/Unsupported protocol version|400/); + await client.close().catch(() => {}); +}); + +verifies('typescript:hosting:entry:notification-202', async ({ protocolVersion }: TestArgs) => { + await using endpoint = await startEndpoint(greetFactory, { legacy: 'stateless' }); + + // 2025 leg: an envelope-less notification rides the legacy stateless slot. + // 2026 leg: the notification carries the per-request envelope and a method + // the 2026-07-28 registry defines. + const notification = + protocolVersion === LEGACY + ? { jsonrpc: '2.0', method: 'notifications/initialized' } + : { + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId: 'never-issued', reason: 'probe', _meta: modernEnvelope() } + }; + + const response = await fetch(endpoint.baseUrl, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(notification) + }); + expect(response.status).toBe(202); + expect(await response.text()).toBe(''); +}); diff --git a/test/e2e/scenarios/stdio-dual-era.test.ts b/test/e2e/scenarios/stdio-dual-era.test.ts new file mode 100644 index 0000000000..46a2406ea1 --- /dev/null +++ b/test/e2e/scenarios/stdio-dual-era.test.ts @@ -0,0 +1,88 @@ +/** + * Self-contained test bodies for dual-era stdio serving. + * + * Like the other transport:stdio scenarios these do not use `wire()`: each + * body spawns the dual-era fixture server in + * `fixtures/dual-era-stdio-server.ts` (eraSupport: 'dual-era', unchanged + * StdioServerTransport) as a real child process via {@link StdioClientTransport}. + * The matrix `transport` arg is ignored (the requirement lists + * `transports: ['stdio']`); the spec-version axis selects which client drives + * the cell — a plain 2025 client over `initialize`, or the auto-negotiating + * client reaching 2026-07-28 over `server/discover` on the same kind of pipe. + */ + +import { fileURLToPath } from 'node:url'; + +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; + +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +/** Absolute path to the runnable dual-era fixture server (executed with tsx). */ +const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/dual-era-stdio-server.ts', import.meta.url)); + +/** E2E package root — spawn cwd so node/tsx resolve the local toolchain and workspace packages. */ +const E2E_ROOT = fileURLToPath(new URL('../', import.meta.url)); + +const MODERN = '2026-07-28'; + +verifies('typescript:transport:stdio:dual-era-serving', async ({ protocolVersion }: TestArgs) => { + const transport = new StdioClientTransport({ + command: process.execPath, + args: ['--import', 'tsx', FIXTURE_PATH], + cwd: E2E_ROOT + }); + + if (protocolVersion === '2025-11-25') { + // Legacy leg: a plain 2025 client is served via initialize, exactly as + // against an undeclared server. + const client = new Client({ name: 'plain-2025-client', version: '0' }); + try { + await client.connect(transport); + expect(client.getNegotiatedProtocolVersion()).toBe(protocolVersion); + const result = await client.callTool({ name: 'echo', arguments: { text: 'legacy leg' } }); + expect(result.isError).toBeFalsy(); + expect(result.content).toEqual([{ type: 'text', text: 'legacy leg' }]); + } finally { + await client.close(); + await transport.close(); + } + return; + } + + // Modern leg: the auto-negotiating client reaches 2026-07-28 via + // server/discover on the pipe (no initialize is ever written) and + // tools/call round-trips with the per-request envelope. + const sentMethods: string[] = []; + const originalSend = transport.send.bind(transport); + transport.send = async message => { + if ('method' in message) sentMethods.push(message.method); + return originalSend(message); + }; + + const client = new Client({ name: 'auto-client', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + try { + await client.connect(transport); + expect(client.getNegotiatedProtocolVersion()).toBe(protocolVersion); + expect(sentMethods).not.toContain('initialize'); + expect(sentMethods[0]).toBe('server/discover'); + + const envelope = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'auto-client', version: '0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; + const result = (await client.request({ + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'modern leg' }, _meta: envelope } + })) as CallToolResult; + expect(result.content).toEqual([{ type: 'text', text: 'modern leg' }]); + } finally { + await client.close(); + await transport.close(); + } +}); From 8b5cd04823613722f324a72b82c3fa2ec211409e Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 11:18:54 +0000 Subject: [PATCH 10/14] test(e2e): assert result stamping and cache-field suppression over the entry Two new requirement rows and a scenario file driving a fully cache-hint configured factory through createMcpHandler over real HTTP: - the 2026-07-28 cell does typed tools/list and resources/read round trips with the negotiating client and asserts on the captured wire bytes that the results carry resultType 'complete' and the required ttlMs/cacheScope fields, with the per-resource cacheHint visibly winning over the per-operation hint (documented precedence); - the 2025-11-25 cell serves the same factory to a plain client through the legacy stateless slot and asserts none of that vocabulary appears anywhere in the response bytes. --- test/e2e/requirements.ts | 16 ++ .../scenarios/hosting-entry-stamping.test.ts | 197 ++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 test/e2e/scenarios/hosting-entry-stamping.test.ts diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index cb4a2d9417..2f2b591bc3 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2228,6 +2228,22 @@ export const REQUIREMENTS: Record = { transports: ['streamableHttp'], note: 'The entry is an HTTP-only surface; each cell hosts the handler node face on a real node:http listener (the matrix transport arg is ignored, fixed to a single streamableHttp-labelled cell) and the spec-version axis selects which leg the notification rides.' }, + 'typescript:hosting:entry:modern-cacheable-stamping': { + source: 'sdk', + behavior: + 'Typed tools/list and resources/read round trips negotiated on 2026-07-28 over a createMcpHandler endpoint succeed, and the wire results carry resultType "complete" plus the required ttlMs/cacheScope fields, with configured cache hints winning per the documented precedence (per-resource cacheHint over the per-operation cacheHints entry over the ttlMs 0 / cacheScope private defaults).', + transports: ['streamableHttp'], + addedInSpecVersion: '2026-07-28', + note: 'The entry is an HTTP-only surface; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and the requirement is fixed to a single streamableHttp-labelled cell on the 2026-07-28 axis.' + }, + 'typescript:hosting:entry:legacy-cacheable-suppression': { + source: 'sdk', + behavior: + 'A factory with every cache-hint author configured (per-operation cacheHints and a per-resource cacheHint), served to a plain 2025 client through the legacy stateless slot of a createMcpHandler endpoint, answers tools/list and resources/read with no resultType, ttlMs, cacheScope or cacheHint vocabulary anywhere in the response bytes.', + transports: ['streamableHttp'], + removedInSpecVersion: '2026-07-28', + note: 'The suppression invariant is a statement about 2025-era serving, so the requirement is bounded to the 2025-11-25 axis; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and fixed to a single streamableHttp-labelled cell.' + }, 'typescript:transport:stdio:dual-era-serving': { source: 'sdk', behavior: diff --git a/test/e2e/scenarios/hosting-entry-stamping.test.ts b/test/e2e/scenarios/hosting-entry-stamping.test.ts new file mode 100644 index 0000000000..8baa63b770 --- /dev/null +++ b/test/e2e/scenarios/hosting-entry-stamping.test.ts @@ -0,0 +1,197 @@ +/** + * Result stamping and cache-field fill, end to end over the dual-era HTTP + * entry (`createMcpHandler`), with the era boundary asserted on the wire: + * + * - 2026-07-28 cells: typed tools/list and resources/read round trips through + * the negotiating client succeed, and the captured wire results carry + * `resultType: 'complete'` plus the required `ttlMs`/`cacheScope` fields, + * with configured cache hints visibly winning per the documented precedence + * (per-resource hint over the per-operation hint over the + * `{ ttlMs: 0, cacheScope: 'private' }` defaults). + * - 2025-11-25 cells: the same fully cache-hint-configured factory served to a + * plain client through the legacy stateless slot answers the same calls with + * none of that vocabulary anywhere in the response bytes. + * + * Like hosting-entry.test.ts these bodies host the handler's node face on a + * real node:http listener and do not use `wire()`. + */ +import type { Server as HttpServer } from 'node:http'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { McpHttpHandler, McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { expect } from 'vitest'; +import { z } from 'zod/v4'; + +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const LEGACY = '2025-11-25'; +const MODERN = '2026-07-28'; + +/** The cache-field vocabulary that must never appear on a 2025-era response. */ +const CACHE_VOCABULARY = ['"resultType"', '"ttlMs"', '"cacheScope"', '"cacheHint"'] as const; + +function modernEnvelope() { + return { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'e2e-stamping-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; +} + +/** + * One ctx-taking factory with every cache-hint author configured: + * - a per-operation hint for tools/list (the funnel-built result with no other author), + * - a per-operation hint for resources/read AND a per-resource hint on the + * registered resource, so the documented precedence (per-resource wins) is + * observable on the wire. + */ +function cacheConfiguredFactory(_ctx: McpRequestContext): McpServer { + const server = new McpServer( + { name: 'e2e-entry-cache', version: '1.0.0' }, + { + capabilities: { tools: {}, resources: {} }, + cacheHints: { + 'tools/list': { ttlMs: 60_000, cacheScope: 'public' }, + 'resources/read': { ttlMs: 90_000, cacheScope: 'public' } + } + } + ); + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name}` }] + })); + server.registerResource('note', 'memo://note', { cacheHint: { ttlMs: 12_000, cacheScope: 'private' } }, async uri => ({ + contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'cached note' }] + })); + return server; +} + +interface Endpoint extends AsyncDisposable { + baseUrl: URL; + handler: McpHttpHandler; +} + +async function startEndpoint(): Promise { + const handler = createMcpHandler(cacheConfiguredFactory, { legacy: 'stateless' }); + const httpServer: HttpServer = createServer((req, res) => void handler.node(req, res)); + const baseUrl = await listenOnRandomPort(httpServer); + return { + baseUrl, + handler, + [Symbol.asyncDispose]: async () => { + await handler.close(); + await new Promise((resolve, reject) => httpServer.close(error => (error ? reject(error) : resolve()))); + } + }; +} + +/** Records every HTTP response body the client receives so wire bytes can be asserted alongside the typed results. */ +function recordingFetch(responseBodies: string[]): typeof fetch { + return async (input, init) => { + const response = await fetch(input, init); + responseBodies.push(await response.clone().text()); + return response; + }; +} + +/** Parses a captured response body (plain JSON or SSE-framed) into its JSON-RPC messages. */ +function jsonRpcMessagesFrom(text: string): Array> { + if (text.trim() === '') return []; + if (text.includes('data: ')) { + return text + .split('\n') + .filter(line => line.startsWith('data: ')) + .map(line => JSON.parse(line.slice(6)) as Record); + } + try { + const parsed = JSON.parse(text) as Record | Array>; + return Array.isArray(parsed) ? parsed : [parsed]; + } catch { + return []; + } +} + +/** Finds the wire result of the response message whose result carries the given key. */ +function wireResultWith(responseBodies: string[], key: string): Record | undefined { + for (const body of responseBodies) { + for (const message of jsonRpcMessagesFrom(body)) { + const result = message.result as Record | undefined; + if (result && key in result) return result; + } + } + return undefined; +} + +verifies('typescript:hosting:entry:modern-cacheable-stamping', async (_args: TestArgs) => { + await using endpoint = await startEndpoint(); + + const responseBodies: string[] = []; + const client = new Client({ name: 'e2e-stamping-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl, { fetch: recordingFetch(responseBodies) })); + + try { + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + // Typed round trips (the 2026 wire result schemas require the cache + // fields, so a successful decode is itself part of the assertion). + const list = (await client.request({ method: 'tools/list', params: { _meta: modernEnvelope() } })) as { + tools: Array<{ name: string }>; + }; + expect(list.tools.map(tool => tool.name)).toEqual(['greet']); + + const read = (await client.request({ + method: 'resources/read', + params: { uri: 'memo://note', _meta: modernEnvelope() } + })) as { contents: Array<{ text?: string }> }; + expect(read.contents[0]?.text).toBe('cached note'); + + // Wire-level: resultType is stamped and the cache fields carry the + // configured hints. tools/list has only the per-operation author; + // resources/read shows the per-resource hint winning over the + // per-operation hint (documented precedence). + const listResult = wireResultWith(responseBodies, 'tools'); + expect(listResult).toBeDefined(); + expect(listResult).toMatchObject({ resultType: 'complete', ttlMs: 60_000, cacheScope: 'public' }); + + const readResult = wireResultWith(responseBodies, 'contents'); + expect(readResult).toBeDefined(); + expect(readResult).toMatchObject({ resultType: 'complete', ttlMs: 12_000, cacheScope: 'private' }); + } finally { + await client.close(); + } +}); + +verifies('typescript:hosting:entry:legacy-cacheable-suppression', async (_args: TestArgs) => { + await using endpoint = await startEndpoint(); + + const responseBodies: string[] = []; + const client = new Client({ name: 'plain-2025-client', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl, { fetch: recordingFetch(responseBodies) })); + + try { + expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY); + + // The same calls, typed, on the 2025 leg (served through the legacy stateless slot). + const tools = await client.listTools(); + expect(tools.tools.map(tool => tool.name)).toEqual(['greet']); + const read = await client.readResource({ uri: 'memo://note' }); + const firstContent = read.contents[0]; + expect(firstContent && 'text' in firstContent ? firstContent.text : undefined).toBe('cached note'); + + // None of the 2026 cache vocabulary appears anywhere in the bytes of + // any response of this conversation, even though every cache-hint + // author is configured on the factory. + const conversation = responseBodies.join('\n'); + expect(conversation).toContain('"tools"'); + expect(conversation).toContain('"contents"'); + for (const term of CACHE_VOCABULARY) { + expect(conversation).not.toContain(term); + } + } finally { + await client.close(); + } +}); From fad1aa7fee69883eac65b1b6f2115bacc20acf36 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 12:48:54 +0000 Subject: [PATCH 11/14] test(integration): resolve workspace packages from source in the examples smoke suite Spawn the example programs with the integration package root as the working directory so tsx picks up its tsconfig and resolves @modelcontextprotocol/* specifiers to the workspace package sources. The suite no longer depends on built dist output (the CI job that runs this package does not build) and is no longer exposed to dist being rewritten mid-run, so the bounded retry on the missing-dist failure signature is removed. Also stop asserting a clean shutdown after a probe has already failed: the failure path now kills the child outright and rethrows the original error, so shutdown assertions cannot mask the probe failure. --- .../test/examples/dualEraExamples.test.ts | 98 +++++++++---------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/test/integration/test/examples/dualEraExamples.test.ts b/test/integration/test/examples/dualEraExamples.test.ts index c5729f9155..ee2d60776b 100644 --- a/test/integration/test/examples/dualEraExamples.test.ts +++ b/test/integration/test/examples/dualEraExamples.test.ts @@ -17,23 +17,26 @@ * each spawn (listeners only — `lsof -sTCP:LISTEN`, never a bare kill by * port), and the spawned child is always stopped by PID. * - * The examples resolve the workspace packages through their published dist - * entry points, so the suite requires `pnpm build:all` to have run. During a - * full integration run, cloudflareWorkers.test.ts repacks the server package - * (`pnpm pack` → prepack → build), which transiently rewrites - * packages/server/dist; an example spawned inside that window dies with a - * missing-dist module error. Such failures are retried a bounded number of - * times so only genuine example breakage fails the suite. + * The examples are spawned with this package's root as the working directory, + * so tsx picks up test/integration/tsconfig.json and its paths map every + * `@modelcontextprotocol/*` specifier to the workspace package sources. That + * keeps the suite independent of built dist output (the CI job running this + * package does not build) and of anything rewriting dist mid-run; the stdio + * client example's own nested spawn inherits the working directory, so the + * server example it launches resolves the same way. Running the examples by + * hand from the repo root still resolves through the published dist entry + * points and therefore still needs `pnpm build:all` first. */ import type { ChildProcess } from 'node:child_process'; import { spawn } from 'node:child_process'; import path from 'node:path'; -import { setTimeout as delay } from 'node:timers/promises'; import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; const REPO_ROOT = path.resolve(__dirname, '../../../..'); +/** This package's root: the spawn cwd, so tsx resolves workspace packages from source via the package tsconfig. */ +const INTEGRATION_ROOT = path.resolve(__dirname, '../..'); const STDIO_CLIENT_EXAMPLE = path.join(REPO_ROOT, 'examples/client/src/dualEraStdioClient.ts'); const HTTP_SERVER_EXAMPLE = path.join(REPO_ROOT, 'examples/server/src/dualEraStreamableHttp.ts'); @@ -44,10 +47,6 @@ const MODERN = '2026-07-28'; const EXAMPLE_PORT = 3000; const EXAMPLE_URL = `http://localhost:${EXAMPLE_PORT}/mcp`; -/** Failure signature of a workspace dist being rewritten underneath a spawned example (see the file header). */ -const MISSING_DIST_PATTERN = /(ERR_MODULE_NOT_FOUND|ENOENT)[\s\S]*[/\\]dist[/\\]/; -const MAX_ATTEMPTS = 3; - interface RunningExample { child: ChildProcess; exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>; @@ -71,7 +70,7 @@ describe('dual-era examples run as real programs', () => { function spawnExample(scriptPath: string, env: Record = {}): RunningExample { const child = spawn(process.execPath, ['--import', 'tsx', scriptPath], { - cwd: REPO_ROOT, + cwd: INTEGRATION_ROOT, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] }); @@ -91,14 +90,8 @@ describe('dual-era examples run as real programs', () => { } it('dualEraStdioClient drives both legs against the stdio server example and exits cleanly', async () => { - let example = spawnExample(STDIO_CLIENT_EXAMPLE); - let { code, signal } = await example.exited; - for (let attempt = 1; attempt < MAX_ATTEMPTS && code !== 0 && MISSING_DIST_PATTERN.test(example.stderr()); attempt++) { - // A workspace dist was being rewritten underneath the example; wait it out and rerun. - await delay(10_000); - example = spawnExample(STDIO_CLIENT_EXAMPLE); - ({ code, signal } = await example.exited); - } + const example = spawnExample(STDIO_CLIENT_EXAMPLE); + const { code, signal } = await example.exited; const stdout = example.stdout(); expect(code, `expected exit 0\nstdout:\n${stdout}\nstderr:\n${example.stderr()}`).toBe(0); @@ -133,34 +126,32 @@ describe('dual-era examples run as real programs', () => { } async function startHttpExample(mode: 'none' | 'stateless' | 'byo'): Promise { - for (let attempt = 1; ; attempt++) { - await clearStalePortListeners(); - const example = spawnExample(HTTP_SERVER_EXAMPLE, { MCP_LEGACY_MODE: mode }); - // Wait for the listening line, but stop waiting as soon as the child exits. - let exited = false; - void example.exited.then(() => { - exited = true; - }); - await vi.waitFor( - () => { - if (!example.stdout().includes('Dual-era MCP server listening') && !exited) throw new Error('not listening yet'); - }, - { timeout: 60_000, interval: 100 } - ); - if (example.stdout().includes('Dual-era MCP server listening')) { - expect(example.stdout()).toContain(`legacy mode: ${mode}`); - return example; - } - if (attempt < MAX_ATTEMPTS && MISSING_DIST_PATTERN.test(example.stderr())) { - // A workspace dist was being rewritten underneath the example; wait it out and respawn. - await delay(10_000); - continue; - } + await clearStalePortListeners(); + const example = spawnExample(HTTP_SERVER_EXAMPLE, { MCP_LEGACY_MODE: mode }); + // Wait for the listening line, but stop waiting as soon as the child exits. + let exited = false; + void example.exited.then(() => { + exited = true; + }); + await vi.waitFor( + () => { + if (!example.stdout().includes('Dual-era MCP server listening') && !exited) throw new Error('not listening yet'); + }, + { timeout: 60_000, interval: 100 } + ); + if (!example.stdout().includes('Dual-era MCP server listening')) { throw new Error(`example exited before listening\nstdout:\n${example.stdout()}\nstderr:\n${example.stderr()}`); } + expect(example.stdout()).toContain(`legacy mode: ${mode}`); + return example; } - /** Stops the spawned example by PID and asserts it shuts down cleanly (the example handles SIGINT itself). */ + /** + * Stops the spawned example by PID and asserts it shuts down cleanly (the + * example handles SIGINT itself). Only call this on the success path: when + * a probe has already failed, kill the child outright instead so the + * shutdown assertions can never mask the original failure. + */ async function stopHttpExample(example: RunningExample): Promise { if (example.child.exitCode === null && example.child.signalCode === null) { example.child.kill('SIGINT'); @@ -170,6 +161,13 @@ describe('dual-era examples run as real programs', () => { expect(code).toBe(0); } + /** Best-effort teardown after a failed probe: kill the child and rethrow the original failure unchanged. */ + async function killAndRethrow(example: RunningExample, error: unknown): Promise { + example.child.kill('SIGKILL'); + await example.exited; + throw error; + } + function modernEnvelope() { return { [PROTOCOL_VERSION_META_KEY]: MODERN, @@ -239,9 +237,10 @@ describe('dual-era examples run as real programs', () => { expect(initResult?.serverInfo?.name).toBe('dual-era-server'); await probeModernPath(); - } finally { - await stopHttpExample(example); + } catch (error) { + await killAndRethrow(example, error); } + await stopHttpExample(example); }); it('dualEraStreamableHttp with MCP_LEGACY_MODE=none rejects 2025-shaped initialize and still serves the modern path', async () => { @@ -254,8 +253,9 @@ describe('dual-era examples run as real programs', () => { expect(error?.data?.supported).toContain(MODERN); await probeModernPath(); - } finally { - await stopHttpExample(example); + } catch (error) { + await killAndRethrow(example, error); } + await stopHttpExample(example); }); }); From 05cf3159f5dbe5b7086529f795e06bde1fe72966 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 12:53:05 +0000 Subject: [PATCH 12/14] test(e2e): tighten the dual-era entry assertions to their requirement texts - dual-era-one-factory, modern cell: record the request bodies and assert the never-initialize clause on the wire (negotiation rides server/discover only). - strict-rejects-legacy: close the probing client in a finally block. - modern-cacheable-stamping: add a resources/list round trip so a third precedence rung (defaults fill for a result with no configured author) is observed on the wire, and align the requirement text with what the cell observes; note that the handler-returned top rung stays unit-pinned. - notification-202: note in the manifest that the cells pin the HTTP contract only; instance delivery is pinned at unit level. --- test/e2e/requirements.ts | 6 ++-- .../scenarios/hosting-entry-stamping.test.ts | 32 +++++++++++++------ test/e2e/scenarios/hosting-entry.test.ts | 26 ++++++++++++--- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 2f2b591bc3..46a3f5d756 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2226,15 +2226,15 @@ export const REQUIREMENTS: Record = { behavior: 'A POST carrying only a notification is answered 202 Accepted with an empty body by a createMcpHandler endpoint on both legs: an envelope-less notification through the legacy stateless slot and an envelope-carrying notification on the modern path.', transports: ['streamableHttp'], - note: 'The entry is an HTTP-only surface; each cell hosts the handler node face on a real node:http listener (the matrix transport arg is ignored, fixed to a single streamableHttp-labelled cell) and the spec-version axis selects which leg the notification rides.' + note: 'The entry is an HTTP-only surface; each cell hosts the handler node face on a real node:http listener (the matrix transport arg is ignored, fixed to a single streamableHttp-labelled cell) and the spec-version axis selects which leg the notification rides. The cells pin the HTTP contract only (status code and empty body); delivery of the notification to the per-request server instance is pinned at unit level.' }, 'typescript:hosting:entry:modern-cacheable-stamping': { source: 'sdk', behavior: - 'Typed tools/list and resources/read round trips negotiated on 2026-07-28 over a createMcpHandler endpoint succeed, and the wire results carry resultType "complete" plus the required ttlMs/cacheScope fields, with configured cache hints winning per the documented precedence (per-resource cacheHint over the per-operation cacheHints entry over the ttlMs 0 / cacheScope private defaults).', + 'Typed tools/list, resources/read and resources/list round trips negotiated on 2026-07-28 over a createMcpHandler endpoint succeed, and the wire results carry resultType "complete" plus the required ttlMs/cacheScope fields, with the configured-hint precedence observable on the wire: the per-resource cacheHint wins over the per-operation cacheHints entry (resources/read), a per-operation hint wins over the defaults (tools/list), and a result with no configured author is filled with the ttlMs 0 / cacheScope private defaults (resources/list).', transports: ['streamableHttp'], addedInSpecVersion: '2026-07-28', - note: 'The entry is an HTTP-only surface; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and the requirement is fixed to a single streamableHttp-labelled cell on the 2026-07-28 axis.' + note: 'The entry is an HTTP-only surface; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and the requirement is fixed to a single streamableHttp-labelled cell on the 2026-07-28 axis. The top precedence rung — a handler-returned ttlMs/cacheScope value winning over every configured hint — is pinned at unit level and not exercised here.' }, 'typescript:hosting:entry:legacy-cacheable-suppression': { source: 'sdk', diff --git a/test/e2e/scenarios/hosting-entry-stamping.test.ts b/test/e2e/scenarios/hosting-entry-stamping.test.ts index 8baa63b770..758811603a 100644 --- a/test/e2e/scenarios/hosting-entry-stamping.test.ts +++ b/test/e2e/scenarios/hosting-entry-stamping.test.ts @@ -2,12 +2,16 @@ * Result stamping and cache-field fill, end to end over the dual-era HTTP * entry (`createMcpHandler`), with the era boundary asserted on the wire: * - * - 2026-07-28 cells: typed tools/list and resources/read round trips through - * the negotiating client succeed, and the captured wire results carry - * `resultType: 'complete'` plus the required `ttlMs`/`cacheScope` fields, - * with configured cache hints visibly winning per the documented precedence - * (per-resource hint over the per-operation hint over the - * `{ ttlMs: 0, cacheScope: 'private' }` defaults). + * - 2026-07-28 cells: typed tools/list, resources/read and resources/list + * round trips through the negotiating client succeed, and the captured wire + * results carry `resultType: 'complete'` plus the required + * `ttlMs`/`cacheScope` fields, with three rungs of the documented precedence + * observable on the wire: the per-resource hint wins over the per-operation + * hint (resources/read), a per-operation hint wins over the defaults + * (tools/list), and a result with no configured author is filled with the + * `{ ttlMs: 0, cacheScope: 'private' }` defaults (resources/list). The top + * rung — a handler-returned value winning over every configured hint — is + * pinned at unit level (encodeContract), not here. * - 2025-11-25 cells: the same fully cache-hint-configured factory served to a * plain client through the legacy stateless slot answers the same calls with * none of that vocabulary anywhere in the response bytes. @@ -149,10 +153,16 @@ verifies('typescript:hosting:entry:modern-cacheable-stamping', async (_args: Tes })) as { contents: Array<{ text?: string }> }; expect(read.contents[0]?.text).toBe('cached note'); + const resourceList = (await client.request({ method: 'resources/list', params: { _meta: modernEnvelope() } })) as { + resources: Array<{ uri: string }>; + }; + expect(resourceList.resources.map(resource => resource.uri)).toEqual(['memo://note']); + // Wire-level: resultType is stamped and the cache fields carry the - // configured hints. tools/list has only the per-operation author; - // resources/read shows the per-resource hint winning over the - // per-operation hint (documented precedence). + // configured hints. tools/list has only the per-operation author (its + // hint wins over the defaults); resources/read shows the per-resource + // hint winning over the per-operation hint; resources/list has no + // configured author at all and is filled with the documented defaults. const listResult = wireResultWith(responseBodies, 'tools'); expect(listResult).toBeDefined(); expect(listResult).toMatchObject({ resultType: 'complete', ttlMs: 60_000, cacheScope: 'public' }); @@ -160,6 +170,10 @@ verifies('typescript:hosting:entry:modern-cacheable-stamping', async (_args: Tes const readResult = wireResultWith(responseBodies, 'contents'); expect(readResult).toBeDefined(); expect(readResult).toMatchObject({ resultType: 'complete', ttlMs: 12_000, cacheScope: 'private' }); + + const resourceListResult = wireResultWith(responseBodies, 'resources'); + expect(resourceListResult).toBeDefined(); + expect(resourceListResult).toMatchObject({ resultType: 'complete', ttlMs: 0, cacheScope: 'private' }); } finally { await client.close(); } diff --git a/test/e2e/scenarios/hosting-entry.test.ts b/test/e2e/scenarios/hosting-entry.test.ts index 3a7d397855..1e2696d721 100644 --- a/test/e2e/scenarios/hosting-entry.test.ts +++ b/test/e2e/scenarios/hosting-entry.test.ts @@ -89,12 +89,23 @@ verifies('typescript:hosting:entry:dual-era-one-factory', async ({ protocolVersi return; } - // 2026-era leg: the auto-negotiating client reaches 2026-07-28 (via - // server/discover) and tools/call is served with the per-request envelope. + // 2026-era leg: the auto-negotiating client reaches 2026-07-28 via + // server/discover — never initialize — and tools/call is served with the + // per-request envelope. + const requestBodies: string[] = []; + const recordingFetch: typeof fetch = async (input, init) => { + if (typeof init?.body === 'string') requestBodies.push(init.body); + return fetch(input, init); + }; const client = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); - await client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl)); + await client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl, { fetch: recordingFetch })); try { expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + // The "(never initialize)" clause of the requirement, asserted on the + // recorded wire traffic: no request body ever carried an initialize, + // and the negotiation rode server/discover. + expect(requestBodies.some(body => body.includes('"initialize"'))).toBe(false); + expect(requestBodies.some(body => body.includes('server/discover'))).toBe(true); const result = (await client.request({ method: 'tools/call', params: { name: 'greet', arguments: { name: 'new friend' }, _meta: modernEnvelope('auto-client') } @@ -157,8 +168,13 @@ verifies('typescript:hosting:entry:strict-rejects-legacy', async (_args: TestArg // The plain SDK client sees the same rejection at connect time. const client = new Client({ name: 'plain-2025-client', version: '1.0.0' }); - await expect(client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl))).rejects.toThrow(/Unsupported protocol version|400/); - await client.close().catch(() => {}); + try { + await expect(client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl))).rejects.toThrow( + /Unsupported protocol version|400/ + ); + } finally { + await client.close().catch(() => {}); + } }); verifies('typescript:hosting:entry:notification-202', async ({ protocolVersion }: TestArgs) => { From bb31983f34b60703020d62c07e6c7376986e2eb0 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 13:02:15 +0000 Subject: [PATCH 13/14] test(e2e): cover sessionful BYO legacy serving and modern streaming through the entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new requirement rows and two scenario files on real node:http listeners: - byo-sessionful-legacy: a real per-session streamable HTTP wiring passed as the createMcpHandler legacy slot serves the full 2025-era session lifecycle through the entry — initialize issues an Mcp-Session-Id, a follow-up POST is served on the session, the body-less GET opens the standalone SSE stream, DELETE tears the session down and a request on the dead session answers 404. Exchanges are recorded as they leave the slot, pinning the entry's GET/DELETE routing and untouched forwarding. - modern-lazy-sse-upgrade: on the default response mode a quiet handler is answered as a single JSON body and a handler emitting mid-call notifications upgrades to SSE, with frame order asserted (notifications first, terminal result last) on the captured wire bytes. - modern-response-mode: responseMode 'sse' streams even with no mid-call output; responseMode 'json' answers a plain JSON body and drops the mid-call notifications. --- test/e2e/requirements.ts | 24 +++ .../scenarios/hosting-entry-session.test.ts | 155 ++++++++++++++ .../scenarios/hosting-entry-streaming.test.ts | 197 ++++++++++++++++++ 3 files changed, 376 insertions(+) create mode 100644 test/e2e/scenarios/hosting-entry-session.test.ts create mode 100644 test/e2e/scenarios/hosting-entry-streaming.test.ts diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 46a3f5d756..3bfb8a19b3 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2244,6 +2244,30 @@ export const REQUIREMENTS: Record = { removedInSpecVersion: '2026-07-28', note: 'The suppression invariant is a statement about 2025-era serving, so the requirement is bounded to the 2025-11-25 axis; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and fixed to a single streamableHttp-labelled cell.' }, + 'typescript:hosting:entry:byo-sessionful-legacy': { + source: 'sdk', + behavior: + 'A real sessionful legacy wiring (per-session WebStandardStreamableHTTPServerTransport instances keyed by Mcp-Session-Id) passed as the createMcpHandler legacy slot value serves the full 2025-era session lifecycle through the entry: initialize issues an Mcp-Session-Id, a follow-up POST is served on that session, GET opens the standalone SSE stream, and DELETE tears the session down (a request carrying the dead session id answers 404).', + transports: ['streamableHttp'], + removedInSpecVersion: '2026-07-28', + note: 'The lifecycle is a statement about 2025-era serving through the bring-your-own legacy slot, so the requirement is bounded to the 2025-11-25 axis; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and fixed to a single streamableHttp-labelled cell. It pins the entry routing of body-less GET and DELETE to the legacy slot and byte-untouched forwarding to the bring-your-own handler.' + }, + 'typescript:hosting:entry:modern-lazy-sse-upgrade': { + source: 'sdk', + behavior: + 'On the default response mode, a modern (2026-07-28) request exchange over a createMcpHandler endpoint is answered as a single JSON body when the handler emits nothing before its result, and upgrades to an SSE stream when the handler emits related notifications mid-call: the response content-type becomes text/event-stream and the frames carry the notifications in emission order with the terminal result as the last frame.', + transports: ['streamableHttp'], + addedInSpecVersion: '2026-07-28', + note: 'The entry is an HTTP-only surface; the cell hosts the handler node face on a real node:http listener and drives it with the negotiating client over a recording fetch, so the matrix transport arg is ignored and the requirement is fixed to a single streamableHttp-labelled cell on the 2026-07-28 axis.' + }, + 'typescript:hosting:entry:modern-response-mode': { + source: 'sdk', + behavior: + 'The createMcpHandler responseMode option shapes modern (2026-07-28) request exchanges end to end: "sse" answers over an SSE stream even when the handler emits nothing before its result, and "json" answers with a single JSON body whose only payload is the terminal result — mid-call notifications are dropped, not buffered.', + transports: ['streamableHttp'], + addedInSpecVersion: '2026-07-28', + note: 'The entry is an HTTP-only surface; the cell hosts one endpoint per responseMode value on a real node:http listener and drives both with the negotiating client over a recording fetch, so the matrix transport arg is ignored and the requirement is fixed to a single streamableHttp-labelled cell on the 2026-07-28 axis.' + }, 'typescript:transport:stdio:dual-era-serving': { source: 'sdk', behavior: diff --git a/test/e2e/scenarios/hosting-entry-session.test.ts b/test/e2e/scenarios/hosting-entry-session.test.ts new file mode 100644 index 0000000000..afc8e7df30 --- /dev/null +++ b/test/e2e/scenarios/hosting-entry-session.test.ts @@ -0,0 +1,155 @@ +/** + * Sessionful 2025-era serving through the dual-era HTTP entry's + * bring-your-own legacy slot. + * + * The legacy slot value is a real sessionful wiring — one + * WebStandardStreamableHTTPServerTransport per session, kept in a map keyed by + * the Mcp-Session-Id the transport itself issues (the documented sessionful + * hosting pattern) — and a plain 2025 SDK client drives the full session + * lifecycle through `createMcpHandler` over a real socket: initialize issues a + * session id, a follow-up POST is served on that session, the body-less GET + * opens the standalone SSE stream, and DELETE tears the session down. Every + * exchange the slot serves is recorded as it leaves the wiring, so the entry's + * routing of GET/DELETE (no envelope, no body → legacy slot) and its + * byte-untouched forwarding to the bring-your-own handler are pinned directly. + * + * Like hosting-entry.test.ts these bodies host the handler's node face on a + * real node:http listener and do not use `wire()`. + */ +import { randomUUID } from 'node:crypto'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import type { LegacyHttpHandler, McpHandlerRequestOptions, McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { expect, vi } from 'vitest'; +import { z } from 'zod/v4'; + +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const LEGACY = '2025-11-25'; + +/** The factory backing the modern path; this cell never drives it (the lifecycle under test is the legacy slot's). */ +function modernFactory(_ctx: McpRequestContext): McpServer { + const server = new McpServer({ name: 'e2e-entry-session', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name} (modern)` }] + })); + return server; +} + +verifies('typescript:hosting:entry:byo-sessionful-legacy', async (_args: TestArgs) => { + // The documented sessionful wiring, passed as the bring-your-own legacy + // slot value: a fresh transport per initialize, kept in a map keyed by the + // Mcp-Session-Id it issues; later requests are routed by that header. + const sessions = new Map(); + const closedSessions: string[] = []; + const sessionServers: McpServer[] = []; + + async function routeSessionRequest(request: Request, options?: McpHandlerRequestOptions): Promise { + const sessionId = request.headers.get('mcp-session-id'); + if (sessionId !== null) { + const existing = sessions.get(sessionId); + if (existing !== undefined) return existing.handleRequest(request, options); + // A request for a session this wiring no longer (or never) knew — + // the documented sessionful pattern answers 404. + return Response.json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }, { status: 404 }); + } + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: randomUUID, + onsessioninitialized: id => void sessions.set(id, transport), + onsessionclosed: id => { + closedSessions.push(id); + sessions.delete(id); + } + }); + const server = new McpServer({ name: 'byo-session-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name} (byo session)` }] + })); + sessionServers.push(server); + await server.connect(transport); + return transport.handleRequest(request, options); + } + + // Every exchange the entry forwards to the bring-your-own slot, recorded + // as it leaves the wiring: this is what proves the GET/DELETE routing. + const slotExchanges: Array<{ method: string; status: number; contentType: string }> = []; + const sessionfulLegacy: LegacyHttpHandler = async (request, options) => { + const response = await routeSessionRequest(request, options); + slotExchanges.push({ + method: request.method.toUpperCase(), + status: response.status, + contentType: response.headers.get('content-type') ?? '' + }); + return response; + }; + + const handler = createMcpHandler(modernFactory, { legacy: sessionfulLegacy }); + const httpServer = createServer((req, res) => void handler.node(req, res)); + const baseUrl = await listenOnRandomPort(httpServer); + + const clientTransport = new StreamableHTTPClientTransport(baseUrl); + const client = new Client({ name: 'plain-2025-client', version: '1.0.0' }); + try { + // initialize → the bring-your-own transport issues an Mcp-Session-Id. + // (The stateless slot never issues one, so a defined session id alone + // proves the request reached the bring-your-own wiring.) + await client.connect(clientTransport); + expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY); + const sessionId = clientTransport.sessionId; + expect(sessionId).toBeDefined(); + expect(sessions.has(sessionId!)).toBe(true); + + // Follow-up POST on the session: served by the same per-session instance. + const result = await client.callTool({ name: 'greet', arguments: { name: 'session friend' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hello session friend (byo session)' }]); + expect(clientTransport.sessionId).toBe(sessionId); + + // GET route: the client opens its standalone SSE stream after + // initialization; the entry routes the body-less GET (no envelope) to + // the legacy slot, which answers it with the stream. + await vi.waitFor( + () => { + const get = slotExchanges.find(exchange => exchange.method === 'GET'); + if (get === undefined) throw new Error('the standalone GET stream has not reached the legacy slot yet'); + expect(get.status).toBe(200); + expect(get.contentType).toContain('text/event-stream'); + }, + { timeout: 5000, interval: 50 } + ); + + // DELETE route: terminating the session goes through the entry to the + // bring-your-own transport, which tears the session down. + await clientTransport.terminateSession(); + expect(closedSessions).toEqual([sessionId]); + const deleteExchange = slotExchanges.find(exchange => exchange.method === 'DELETE'); + expect(deleteExchange?.status).toBe(200); + + // Stop the client before probing the dead session so its standalone + // stream cannot reconnect underneath the assertion. + await client.close(); + + // The dead session is gone: a POST carrying its id is answered 404 by + // the bring-your-own wiring, not silently re-served by anything else. + const stale = await fetch(baseUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId!, + 'mcp-protocol-version': LEGACY + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 99, method: 'tools/list', params: {} }) + }); + expect(stale.status).toBe(404); + await stale.text(); + } finally { + await client.close().catch(() => {}); + for (const server of sessionServers) await server.close().catch(() => {}); + await handler.close(); + await new Promise((resolve, reject) => httpServer.close(error => (error ? reject(error) : resolve()))); + } +}); diff --git a/test/e2e/scenarios/hosting-entry-streaming.test.ts b/test/e2e/scenarios/hosting-entry-streaming.test.ts new file mode 100644 index 0000000000..fda8a3a98b --- /dev/null +++ b/test/e2e/scenarios/hosting-entry-streaming.test.ts @@ -0,0 +1,197 @@ +/** + * Modern-era (2026-07-28) response streaming through the dual-era HTTP entry, + * observed on a real socket: + * + * - default response mode: a handler that emits nothing before its result is + * answered as a single JSON body; a handler that emits related notifications + * mid-call upgrades the response to an SSE stream (content-type + * text/event-stream, notifications framed in emission order, terminal result + * last); + * - `responseMode: 'sse'` always streams, even with no mid-call output; + * - `responseMode: 'json'` never streams and drops mid-call notifications — + * only the terminal result is delivered. + * + * Every body hosts the handler's node face on a real node:http listener and + * drives it with the auto-negotiating client over a recording fetch, so the + * typed result and the raw wire bytes are asserted side by side. Like + * hosting-entry.test.ts these bodies do not use `wire()`. + */ +import type { Server as HttpServer } from 'node:http'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { CallToolResult, CreateMcpHandlerOptions, McpHttpHandler, McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { expect } from 'vitest'; +import { z } from 'zod/v4'; + +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const MODERN = '2026-07-28'; + +/** The per-request `_meta` envelope every 2026-era request carries (attached explicitly until automatic emission lands client-side). */ +function modernEnvelope() { + return { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'e2e-streaming-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; +} + +/** + * One factory with a quiet tool (no streamed output) and a chatty tool (two + * logging notifications emitted before its result), so the lazy upgrade and + * both forced response modes are observable per call. + */ +function streamingFactory(_ctx: McpRequestContext): McpServer { + const server = new McpServer({ name: 'e2e-entry-streaming', version: '1.0.0' }, { capabilities: { tools: {}, logging: {} } }); + server.registerTool('quiet', { inputSchema: z.object({}) }, () => ({ + content: [{ type: 'text', text: 'quiet result' }] + })); + server.registerTool('chatty', { inputSchema: z.object({}) }, async (_args, ctx) => { + await ctx.mcpReq.notify({ method: 'notifications/message', params: { level: 'info', data: 'first' } }); + await ctx.mcpReq.notify({ method: 'notifications/message', params: { level: 'info', data: 'second' } }); + return { content: [{ type: 'text', text: 'chatty result' }] }; + }); + return server; +} + +interface Endpoint extends AsyncDisposable { + baseUrl: URL; + handler: McpHttpHandler; +} + +/** Hosts the handler's node face on a real node:http listener bound to an ephemeral port. */ +async function startEndpoint(options?: CreateMcpHandlerOptions): Promise { + const handler = createMcpHandler(streamingFactory, options); + const httpServer: HttpServer = createServer((req, res) => void handler.node(req, res)); + const baseUrl = await listenOnRandomPort(httpServer); + return { + baseUrl, + handler, + [Symbol.asyncDispose]: async () => { + await handler.close(); + await new Promise((resolve, reject) => httpServer.close(error => (error ? reject(error) : resolve()))); + } + }; +} + +interface RecordedResponse { + status: number; + contentType: string; + body: string; +} + +/** Records every HTTP response (status, content-type, raw body bytes) the client receives. */ +function recordingFetch(responses: RecordedResponse[]): typeof fetch { + return async (input, init) => { + const response = await fetch(input, init); + responses.push({ + status: response.status, + contentType: response.headers.get('content-type') ?? '', + body: await response.clone().text() + }); + return response; + }; +} + +/** The `data:` payloads of an SSE-framed body, parsed, in frame order. */ +function sseDataFrames(body: string): Array> { + return body + .split('\n') + .filter(line => line.startsWith('data: ')) + .map(line => JSON.parse(line.slice('data: '.length)) as Record); +} + +async function connectAutoClient(baseUrl: URL, responses: RecordedResponse[]): Promise { + const client = new Client({ name: 'e2e-streaming-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(baseUrl, { fetch: recordingFetch(responses) })); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + return client; +} + +function callTool(client: Client, name: 'quiet' | 'chatty'): Promise { + return client.request({ + method: 'tools/call', + params: { name, arguments: {}, _meta: modernEnvelope() } + }) as Promise; +} + +verifies('typescript:hosting:entry:modern-lazy-sse-upgrade', async (_args: TestArgs) => { + await using endpoint = await startEndpoint(); + + const responses: RecordedResponse[] = []; + const client = await connectAutoClient(endpoint.baseUrl, responses); + try { + // Quiet handler: nothing emitted before the result → a single JSON body. + const quiet = await callTool(client, 'quiet'); + expect(quiet.content).toEqual([{ type: 'text', text: 'quiet result' }]); + const quietResponse = responses.find(response => response.body.includes('quiet result')); + expect(quietResponse).toBeDefined(); + expect(quietResponse!.status).toBe(200); + expect(quietResponse!.contentType).toContain('application/json'); + + // Chatty handler: the first related notification upgrades the exchange + // to SSE — notifications framed in order, terminal result last. + const chatty = await callTool(client, 'chatty'); + expect(chatty.content).toEqual([{ type: 'text', text: 'chatty result' }]); + const chattyResponse = responses.find(response => response.body.includes('chatty result')); + expect(chattyResponse).toBeDefined(); + expect(chattyResponse!.status).toBe(200); + expect(chattyResponse!.contentType).toContain('text/event-stream'); + + const frames = sseDataFrames(chattyResponse!.body); + expect(frames).toHaveLength(3); + expect(frames[0]).toMatchObject({ method: 'notifications/message', params: { data: 'first' } }); + expect(frames[1]).toMatchObject({ method: 'notifications/message', params: { data: 'second' } }); + expect(frames[2]).toMatchObject({ result: { content: [{ type: 'text', text: 'chatty result' }] } }); + } finally { + await client.close(); + } +}); + +verifies('typescript:hosting:entry:modern-response-mode', async (_args: TestArgs) => { + // One endpoint per responseMode value, both backed by the same factory. + await using sseEndpoint = await startEndpoint({ responseMode: 'sse' }); + await using jsonEndpoint = await startEndpoint({ responseMode: 'json' }); + + // responseMode 'sse': even a handler that emits nothing streams its result. + { + const responses: RecordedResponse[] = []; + const client = await connectAutoClient(sseEndpoint.baseUrl, responses); + try { + const result = await callTool(client, 'quiet'); + expect(result.content).toEqual([{ type: 'text', text: 'quiet result' }]); + const response = responses.find(candidate => candidate.body.includes('quiet result')); + expect(response).toBeDefined(); + expect(response!.status).toBe(200); + expect(response!.contentType).toContain('text/event-stream'); + const frames = sseDataFrames(response!.body); + expect(frames).toHaveLength(1); + expect(frames[0]).toMatchObject({ result: { content: [{ type: 'text', text: 'quiet result' }] } }); + } finally { + await client.close(); + } + } + + // responseMode 'json': mid-call notifications are dropped — the response + // is a plain JSON body whose only payload is the terminal result. + { + const responses: RecordedResponse[] = []; + const client = await connectAutoClient(jsonEndpoint.baseUrl, responses); + try { + const result = await callTool(client, 'chatty'); + expect(result.content).toEqual([{ type: 'text', text: 'chatty result' }]); + const response = responses.find(candidate => candidate.body.includes('chatty result')); + expect(response).toBeDefined(); + expect(response!.status).toBe(200); + expect(response!.contentType).toContain('application/json'); + expect(response!.body).not.toContain('notifications/message'); + } finally { + await client.close(); + } + } +}); From d4df5af4f834a49c33e95ba04e501a0f218183b4 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 13:02:36 +0000 Subject: [PATCH 14/14] test(integration): drop the dual-era examples smoke suite Keep this change focused on the e2e version-matrix coverage of the dual-era serving stack. Executing the example programs as real child processes is worthwhile but separable work with its own concerns (port serialization, source vs dist resolution for spawned processes), so it is removed here and will be proposed on its own. --- .../test/examples/dualEraExamples.test.ts | 261 ------------------ 1 file changed, 261 deletions(-) delete mode 100644 test/integration/test/examples/dualEraExamples.test.ts diff --git a/test/integration/test/examples/dualEraExamples.test.ts b/test/integration/test/examples/dualEraExamples.test.ts deleted file mode 100644 index ee2d60776b..0000000000 --- a/test/integration/test/examples/dualEraExamples.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * Smoke coverage for the dual-era example programs, executed as real child - * processes (the way a reader of the docs would run them): - * - * - `examples/client/src/dualEraStdioClient.ts` is run via tsx and must drive - * both legs against the stdio server example it spawns itself: leg 1 - * negotiates 2025-11-25 over `initialize`, leg 2 negotiates 2026-07-28 over - * `server/discover`, both greet calls succeed, and the program exits 0. - * - `examples/server/src/dualEraStreamableHttp.ts` is run via tsx once per - * `MCP_LEGACY_MODE` value (none / stateless / byo) and probed over real - * HTTP: a 2025-shaped `initialize` (served on the legacy slot, rejected on - * the strict endpoint), `server/discover`, and a `tools/call` carrying the - * 2026 per-request `_meta` envelope. - * - * The HTTP example listens on a hard-coded port (3000) with no override knob, - * so these tests serialize on that port: stale listeners are cleared before - * each spawn (listeners only — `lsof -sTCP:LISTEN`, never a bare kill by - * port), and the spawned child is always stopped by PID. - * - * The examples are spawned with this package's root as the working directory, - * so tsx picks up test/integration/tsconfig.json and its paths map every - * `@modelcontextprotocol/*` specifier to the workspace package sources. That - * keeps the suite independent of built dist output (the CI job running this - * package does not build) and of anything rewriting dist mid-run; the stdio - * client example's own nested spawn inherits the working directory, so the - * server example it launches resolves the same way. Running the examples by - * hand from the repo root still resolves through the published dist entry - * points and therefore still needs `pnpm build:all` first. - */ -import type { ChildProcess } from 'node:child_process'; -import { spawn } from 'node:child_process'; -import path from 'node:path'; - -import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -const REPO_ROOT = path.resolve(__dirname, '../../../..'); -/** This package's root: the spawn cwd, so tsx resolves workspace packages from source via the package tsconfig. */ -const INTEGRATION_ROOT = path.resolve(__dirname, '../..'); -const STDIO_CLIENT_EXAMPLE = path.join(REPO_ROOT, 'examples/client/src/dualEraStdioClient.ts'); -const HTTP_SERVER_EXAMPLE = path.join(REPO_ROOT, 'examples/server/src/dualEraStreamableHttp.ts'); - -const LEGACY = '2025-11-25'; -const MODERN = '2026-07-28'; - -/** The HTTP example's hard-coded listen port (it exposes no override). */ -const EXAMPLE_PORT = 3000; -const EXAMPLE_URL = `http://localhost:${EXAMPLE_PORT}/mcp`; - -interface RunningExample { - child: ChildProcess; - exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>; - stdout: () => string; - stderr: () => string; -} - -describe('dual-era examples run as real programs', () => { - vi.setConfig({ testTimeout: 120_000 }); - - const activeChildren: ChildProcess[] = []; - - afterEach(() => { - // Belt and braces: never let a spawned example outlive its test. - for (const child of activeChildren.splice(0)) { - if (child.exitCode === null && child.signalCode === null) { - child.kill('SIGKILL'); - } - } - }); - - function spawnExample(scriptPath: string, env: Record = {}): RunningExample { - const child = spawn(process.execPath, ['--import', 'tsx', scriptPath], { - cwd: INTEGRATION_ROOT, - env: { ...process.env, ...env }, - stdio: ['ignore', 'pipe', 'pipe'] - }); - activeChildren.push(child); - let stdout = ''; - let stderr = ''; - child.stdout?.on('data', chunk => { - stdout += String(chunk); - }); - child.stderr?.on('data', chunk => { - stderr += String(chunk); - }); - const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(resolve => { - child.on('exit', (code, signal) => resolve({ code, signal })); - }); - return { child, exited, stdout: () => stdout, stderr: () => stderr }; - } - - it('dualEraStdioClient drives both legs against the stdio server example and exits cleanly', async () => { - const example = spawnExample(STDIO_CLIENT_EXAMPLE); - const { code, signal } = await example.exited; - const stdout = example.stdout(); - - expect(code, `expected exit 0\nstdout:\n${stdout}\nstderr:\n${example.stderr()}`).toBe(0); - expect(signal).toBeNull(); - - // Split the transcript at the leg-2 marker so each negotiation and - // greeting is asserted against its own leg. - const legBoundary = stdout.indexOf('--- leg 2'); - expect(legBoundary, `expected the leg-2 marker in stdout:\n${stdout}`).toBeGreaterThan(-1); - const leg1 = stdout.slice(0, legBoundary); - const leg2 = stdout.slice(legBoundary); - - expect(leg1).toContain('--- leg 1'); - expect(leg1).toContain(`negotiated protocol version: ${LEGACY}`); - expect(leg1).toContain('Hello, 2025 client!'); - - expect(leg2).toContain(`negotiated protocol version: ${MODERN}`); - expect(leg2).toContain('Hello, 2026 client!'); - - expect(stdout).toContain('both legs served by the same dual-era stdio server.'); - }); - - // ── HTTP example (one spawn per MCP_LEGACY_MODE value) ────────────────── - - /** Kills stale listeners on the example's hard-coded port (LISTEN sockets only — never a bare kill by port). */ - async function clearStalePortListeners(): Promise { - await new Promise(resolve => { - const cleanup = spawn('sh', ['-c', `lsof -ti:${EXAMPLE_PORT} -sTCP:LISTEN | xargs -r kill`], { stdio: 'ignore' }); - cleanup.on('exit', () => resolve()); - cleanup.on('error', () => resolve()); - }); - } - - async function startHttpExample(mode: 'none' | 'stateless' | 'byo'): Promise { - await clearStalePortListeners(); - const example = spawnExample(HTTP_SERVER_EXAMPLE, { MCP_LEGACY_MODE: mode }); - // Wait for the listening line, but stop waiting as soon as the child exits. - let exited = false; - void example.exited.then(() => { - exited = true; - }); - await vi.waitFor( - () => { - if (!example.stdout().includes('Dual-era MCP server listening') && !exited) throw new Error('not listening yet'); - }, - { timeout: 60_000, interval: 100 } - ); - if (!example.stdout().includes('Dual-era MCP server listening')) { - throw new Error(`example exited before listening\nstdout:\n${example.stdout()}\nstderr:\n${example.stderr()}`); - } - expect(example.stdout()).toContain(`legacy mode: ${mode}`); - return example; - } - - /** - * Stops the spawned example by PID and asserts it shuts down cleanly (the - * example handles SIGINT itself). Only call this on the success path: when - * a probe has already failed, kill the child outright instead so the - * shutdown assertions can never mask the original failure. - */ - async function stopHttpExample(example: RunningExample): Promise { - if (example.child.exitCode === null && example.child.signalCode === null) { - example.child.kill('SIGINT'); - } - const { code, signal } = await example.exited; - expect(signal).toBeNull(); - expect(code).toBe(0); - } - - /** Best-effort teardown after a failed probe: kill the child and rethrow the original failure unchanged. */ - async function killAndRethrow(example: RunningExample, error: unknown): Promise { - example.child.kill('SIGKILL'); - await example.exited; - throw error; - } - - function modernEnvelope() { - return { - [PROTOCOL_VERSION_META_KEY]: MODERN, - [CLIENT_INFO_META_KEY]: { name: 'examples-smoke-client', version: '1.0.0' }, - [CLIENT_CAPABILITIES_META_KEY]: {} - }; - } - - const legacyInitialize = { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { protocolVersion: LEGACY, capabilities: {}, clientInfo: { name: 'legacy-probe', version: '1.0.0' } } - }; - - /** POSTs one JSON-RPC message and returns the HTTP status plus the parsed JSON-RPC response (JSON or SSE body). */ - async function postJsonRpc( - message: Record - ): Promise<{ status: number; message: Record | undefined }> { - const response = await fetch(EXAMPLE_URL, { - method: 'POST', - headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, - body: JSON.stringify(message) - }); - const text = await response.text(); - const contentType = response.headers.get('content-type') ?? ''; - let parsed: Record | undefined; - if (contentType.includes('text/event-stream')) { - parsed = text - .split('\n') - .filter(line => line.startsWith('data: ')) - .map(line => JSON.parse(line.slice(6)) as Record) - .find(candidate => 'result' in candidate || 'error' in candidate); - } else if (text !== '') { - parsed = JSON.parse(text) as Record; - } - return { status: response.status, message: parsed }; - } - - /** The 2026-07-28 path is identical in every slot state: discover advertises it and an enveloped tools/call is served. */ - async function probeModernPath(): Promise { - const discover = await postJsonRpc({ jsonrpc: '2.0', id: 2, method: 'server/discover', params: { _meta: modernEnvelope() } }); - expect(discover.status).toBe(200); - const discoverResult = (discover.message as { result?: { supportedVersions?: string[]; serverInfo?: { name?: string } } }).result; - expect(discoverResult?.supportedVersions).toContain(MODERN); - expect(discoverResult?.serverInfo?.name).toBe('dual-era-server'); - - const call = await postJsonRpc({ - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { name: 'greet', arguments: { name: 'smoke probe' }, _meta: modernEnvelope() } - }); - expect(call.status).toBe(200); - const callResult = (call.message as { result?: { resultType?: string; content?: unknown } }).result; - expect(callResult?.resultType).toBe('complete'); - expect(callResult?.content).toEqual([{ type: 'text', text: 'Hello, smoke probe! (served on the modern protocol era)' }]); - } - - it.each(['stateless', 'byo'] as const)('dualEraStreamableHttp with MCP_LEGACY_MODE=%s serves both eras over real HTTP', async mode => { - const example = await startHttpExample(mode); - try { - const init = await postJsonRpc(legacyInitialize); - expect(init.status).toBe(200); - const initResult = (init.message as { result?: { protocolVersion?: string; serverInfo?: { name?: string } } }).result; - expect(initResult?.protocolVersion).toBe(LEGACY); - expect(initResult?.serverInfo?.name).toBe('dual-era-server'); - - await probeModernPath(); - } catch (error) { - await killAndRethrow(example, error); - } - await stopHttpExample(example); - }); - - it('dualEraStreamableHttp with MCP_LEGACY_MODE=none rejects 2025-shaped initialize and still serves the modern path', async () => { - const example = await startHttpExample('none'); - try { - const init = await postJsonRpc(legacyInitialize); - expect(init.status).toBe(400); - const error = (init.message as { error?: { message?: string; data?: { supported?: string[] } } }).error; - expect(error?.message).toMatch(/unsupported protocol version/i); - expect(error?.data?.supported).toContain(MODERN); - - await probeModernPath(); - } catch (error) { - await killAndRethrow(example, error); - } - await stopHttpExample(example); - }); -});