From bb16f88c4ae851e989e90eae057aa484705707c0 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 04:15:55 +0000 Subject: [PATCH 1/8] 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 247567516d..74efb36fe0 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -253,8 +253,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.' } ]; @@ -277,6 +279,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 fced9eb501..92acc6a6ad 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 d1e661543b..9eedf58f52 100644 --- a/packages/core/test/shared/inboundLadderCellSheet.test.ts +++ b/packages/core/test/shared/inboundLadderCellSheet.test.ts @@ -432,8 +432,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 95bbbfc72176831c3018995c84aa87203373823e Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 04:16:20 +0000 Subject: [PATCH 2/8] 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 72e13e3634..6ce2402cb8 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 ae033ad2ca0af41131e4223b0b0b871fbbe56cb2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 04:16:43 +0000 Subject: [PATCH 3/8] 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 78daf3d5a8..0bab6a0444 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, @@ -37,6 +38,8 @@ import type { ToolUseContent } from '@modelcontextprotocol/core'; import { + assertValidCacheHint, + attachCacheHintFallback, classifyInboundMessage, codecForVersion, CreateMessageResultSchema, @@ -136,6 +139,27 @@ export type ServerOptions = ProtocolOptions & { * @default 'legacy' */ eraSupport?: 'legacy' | 'dual-era' | 'modern'; + + /** + * 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 + > + >; }; /** @@ -258,6 +282,7 @@ export class Server extends Protocol { * here only for the initialize-scoped accessor. */ private _dualEraInitializeVersion?: string; + private _cacheHints?: ServerOptions['cacheHints']; /** * Callback for when initialization has fully completed (i.e., the client has sent an `notifications/initialized` notification). @@ -277,6 +302,17 @@ export class Server extends Protocol { this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); this._eraSupport = options?.eraSupport ?? 'legacy'; + // 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?.()); @@ -487,14 +523,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 3322549626d559123a77090861783fe277c40903 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 04:16:43 +0000 Subject: [PATCH 4/8] 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 | 44 +++++++-- .../createMcpHandlerCapabilityGate.test.ts | 98 +++++++++++++++++++ 2 files changed, 134 insertions(+), 8 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 a3341c5fed..00f35ddeac 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -34,8 +34,12 @@ import { classifyInboundRequest, CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, + httpStatusForErrorCode, + missingClientCapabilities, + MissingRequiredClientCapabilityError, modernOnlyStrictRejection, requestMetaOf, + requiredClientCapabilitiesForRequest, SdkError, SdkErrorCode, setNegotiatedProtocolVersion, @@ -420,6 +424,33 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa return jsonRpcErrorResponse(400, error.code, error.message, error.data, echoableRequestId(message)); } + 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 }), @@ -432,14 +463,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 e7f577e24200c8a41839b1224b60dd8739901c77 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 04:16:54 +0000 Subject: [PATCH 5/8] 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 ce0602cf71..8f365350e2 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1101,6 +1101,35 @@ can still send them to the 2025-era clients it serves via `initialize`. On a `'d Declaring `eraSupport: 'dual-era'` is also an assertion that your handlers are ready to serve modern-era requests (for example, that they read per-request client identity from `ctx.mcpReq.envelope` rather than the connection-scoped accessors — see the next section). A future release may add per-handler era declarations as the basis for a safe automatic default; for now the connection-level `eraSupport` option is the whole opt-in surface. +### 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 2c1cc4f734741481b4312b19f993595a945a98ee Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 09:21:19 +0000 Subject: [PATCH 6/8] 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 74efb36fe0..14d7b16f54 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -167,8 +167,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). */ @@ -183,9 +188,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[] = [ { @@ -249,14 +256,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 e56eb3009fb6b671ed825738f178a49f06f98804 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 09:21:35 +0000 Subject: [PATCH 7/8] 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 0bab6a0444..0f18b0c029 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, @@ -142,24 +143,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 cefb94b0478bba14f762c21fbedab16040d8c220 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 13:06:11 +0000 Subject: [PATCH 8/8] fix(core): resolve configured cache hints per field between the two configured authors - attachCacheHintFallback previously kept an already-attached per-resource hint as a whole object, so a partial registerResource cacheHint (say only cacheScope) shadowed the per-operation ServerOptions.cacheHints entry entirely and its ttlMs fell back to the default 0. The two configured hints are now combined per field: for each of ttlMs and cacheScope the per-resource value wins when set and the per-operation value fills the fields it leaves unset, with the defaults only applying to fields neither author sets. Handler-returned values, the encode-time validity gate, the configuration-time RangeError and the defaults are unchanged. - Tests: add the field-mixing cases (per-resource cacheScope with per-operation ttlMs and the reverse, both authors setting the same fields, a field neither sets, resources/read with no configuration at all) to the cache-hint suite, plus a unit-level per-field merge case in the encode-contract suite. No existing test cases changed; the cache-hint suite's header comment now says the precedence chain resolves per field. - Docs: the ServerOptions.cacheHints JSDoc, migration.md and the cacheable result changeset now state that resolution is per field, most specific author first. --- .changeset/cacheable-result-cache-fields.md | 2 +- docs/migration.md | 5 +- packages/core/src/shared/resultCacheHints.ts | 29 ++++++++--- .../core/test/wire/encodeContract.test.ts | 6 +++ packages/server/src/server/server.ts | 4 +- .../server/test/server/cacheHints.test.ts | 49 ++++++++++++++++++- 6 files changed, 81 insertions(+), 14 deletions(-) diff --git a/.changeset/cacheable-result-cache-fields.md b/.changeset/cacheable-result-cache-fields.md index 51ddf16247..cb8d917e3f 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. 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. +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); resolution is per field, most specific author first: cache fields returned by a handler win over the per-resource hint, which wins over the per-operation hint, 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/docs/migration.md b/docs/migration.md index 8f365350e2..70ef7df0e8 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1122,8 +1122,9 @@ server.registerResource('config', 'config://app', { cacheHint: { ttlMs: 5_000 } })); ``` -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. +Resolution is per field, most specific author first: for each of `ttlMs` and `cacheScope`, a value returned by the handler itself (when valid) wins over the per-resource `cacheHint`, which wins over `ServerOptions.cacheHints[operation]`, which wins over the default — so a +per-resource hint that sets only one field never suppresses the other field configured at the operation level. 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 diff --git a/packages/core/src/shared/resultCacheHints.ts b/packages/core/src/shared/resultCacheHints.ts index 33a38e95aa..a1786f3a35 100644 --- a/packages/core/src/shared/resultCacheHints.ts +++ b/packages/core/src/shared/resultCacheHints.ts @@ -8,7 +8,8 @@ * * 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), + * (per-registration hint, then the server-level per-operation hint, + * combined per field — see {@linkcode attachCacheHintFallback}), * 3. the conservative defaults `{ ttlMs: 0, cacheScope: 'private' }`. * * The configured hint travels from the (era-blind) server configuration to the @@ -72,19 +73,31 @@ interface CacheHintCarrier { /** * 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). + * Returns the result unchanged when there is nothing to attach. When a more + * specific hint is already attached, the two hints are combined per field + * (most-specific-author-wins for each of `ttlMs` and `cacheScope`): the + * per-registration hint attached by the feature layer keeps every field it + * sets, and the server-level per-operation hint only fills the fields the + * more specific hint leaves unset. */ 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; + const attached = (result as CacheHintCarrier)[RESULT_CACHE_HINT_FALLBACK]; + if (attached === undefined) { + return { ...result, [RESULT_CACHE_HINT_FALLBACK]: hint }; + } + const merged: CacheHint = {}; + const ttlMs = attached.ttlMs ?? hint.ttlMs; + if (ttlMs !== undefined) { + merged.ttlMs = ttlMs; + } + const cacheScope = attached.cacheScope ?? hint.cacheScope; + if (cacheScope !== undefined) { + merged.cacheScope = cacheScope; } - return { ...result, [RESULT_CACHE_HINT_FALLBACK]: hint }; + return { ...result, [RESULT_CACHE_HINT_FALLBACK]: merged }; } /** Reads the configured cache-hint fallback attached to a result, if any. */ diff --git a/packages/core/test/wire/encodeContract.test.ts b/packages/core/test/wire/encodeContract.test.ts index 6a3f1071eb..572376fb01 100644 --- a/packages/core/test/wire/encodeContract.test.ts +++ b/packages/core/test/wire/encodeContract.test.ts @@ -161,6 +161,12 @@ describe('step 2 — the cache fill', () => { const withBoth = attachCacheHintFallback(withSpecific, { ttlMs: 50 }); expect(cacheHintFallbackOf(withBoth)).toEqual({ ttlMs: 2_000 }); }); + + test('attachCacheHintFallback combines hints per field: a less specific hint fills only the fields the attached hint leaves unset', () => { + const withSpecific = attachCacheHintFallback(asResult({}), { cacheScope: 'public' }); + const withBoth = attachCacheHintFallback(withSpecific, { ttlMs: 50, cacheScope: 'private' }); + expect(cacheHintFallbackOf(withBoth)).toEqual({ ttlMs: 50, cacheScope: 'public' }); + }); }); describe('the codec integration (encodeResult applies the contract in pinned order)', () => { diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 0f18b0c029..20e2995923 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -150,7 +150,9 @@ export type ServerOptions = ProtocolOptions & { * 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. + * that resource's `resources/read` results, field by field: a field the + * per-resource hint leaves unset still falls back to the per-operation + * hint configured here. * * Absent hints (or omitting this option entirely) keep today's behavior: * cacheable 2026-07-28 results are emitted with `ttlMs: 0` and diff --git a/packages/server/test/server/cacheHints.test.ts b/packages/server/test/server/cacheHints.test.ts index 727d63c17e..d865062bf6 100644 --- a/packages/server/test/server/cacheHints.test.ts +++ b/packages/server/test/server/cacheHints.test.ts @@ -4,8 +4,8 @@ * - `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 + * - precedence, resolved per field: 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. */ @@ -153,6 +153,51 @@ describe('modern (2026-07-28) responses', () => { expect(result).toMatchObject({ ttlMs: 1_000, cacheScope: 'private' }); }); + it('a per-resource hint setting only cacheScope still takes ttlMs from the per-operation hint (per-field resolution)', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { ttlMs: 1_000 } } }); + mcpServer.registerResource('scoped', 'test://scoped', { cacheHint: { cacheScope: 'public' } }, async uri => ({ + contents: [{ uri: uri.href, text: 'scoped' }] + })); + const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://scoped' })); + expect(result).toMatchObject({ ttlMs: 1_000, cacheScope: 'public' }); + }); + + it('a per-resource hint setting only ttlMs still takes cacheScope from the per-operation hint (per-field resolution)', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { cacheScope: 'public' } } }); + mcpServer.registerResource('timed', 'test://timed', { cacheHint: { ttlMs: 2_000 } }, async uri => ({ + contents: [{ uri: uri.href, text: 'timed' }] + })); + const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://timed' })); + expect(result).toMatchObject({ ttlMs: 2_000, cacheScope: 'public' }); + }); + + it('when both configured hints set the same fields, the per-resource values win for every field', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { ttlMs: 1_000, cacheScope: 'private' } } }); + mcpServer.registerResource('full', 'test://full', { cacheHint: { ttlMs: 2_000, cacheScope: 'public' } }, async uri => ({ + contents: [{ uri: uri.href, text: 'full' }] + })); + const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://full' })); + expect(result).toMatchObject({ ttlMs: 2_000, cacheScope: 'public' }); + }); + + it('a field neither configured author sets falls back to the default', async () => { + const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { ttlMs: 1_000 } } }); + mcpServer.registerResource('partial', 'test://partial', { cacheHint: { ttlMs: 2_000 } }, async uri => ({ + contents: [{ uri: uri.href, text: 'partial' }] + })); + const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://partial' })); + expect(result).toMatchObject({ ttlMs: 2_000, cacheScope: 'private' }); + }); + + it('fills the defaults for resources/read when neither configured author provides a hint', async () => { + const mcpServer = buildMcpServer(); + mcpServer.registerResource('bare', 'test://bare', {}, async uri => ({ + contents: [{ uri: uri.href, text: 'bare' }] + })); + const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://bare' })); + expect(result).toMatchObject({ ttlMs: 0, 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 => ({