diff --git a/.changeset/add-version-negotiation-option.md b/.changeset/add-version-negotiation-option.md new file mode 100644 index 0000000000..334796f879 --- /dev/null +++ b/.changeset/add-version-negotiation-option.md @@ -0,0 +1,11 @@ +--- +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/core': minor +--- + +Add opt-in protocol version negotiation on `ClientOptions.versionNegotiation`. The default is unchanged: without the option (or with `mode: 'legacy'`) the client performs today's 2025 connect sequence byte-identically. `mode: 'auto'` probes the server with `server/discover` at +connect time and conservatively falls back to the plain legacy `initialize` handshake on the same connection unless the outcome is definitive modern evidence (with a supported-versions list that has no 2025-era entry there is nothing to fall back to, and connect rejects +with a typed error instead); a network outage rejects with a typed connect error, and a probe timeout is transport-aware — on stdio it indicates +a legacy server and falls back to `initialize` on the same stream, on HTTP it rejects with a typed timeout error. +`mode: { pin: '' }` negotiates exactly the pinned modern revision with no fallback. Probe policy lives under `probe: { timeoutMs? }` — the probe inherits the standard request timeout. The probe's `MCP-Protocol-Version`/`Mcp-Method` headers derive from the probe +message body; the transport version slot is never touched during negotiation, so legacy-era traffic carries zero 2026 headers by construction. Adds the `SdkErrorCode.EraNegotiationFailed` code for negotiation-phase connect failures. diff --git a/.changeset/cacheable-result-cache-fields.md b/.changeset/cacheable-result-cache-fields.md new file mode 100644 index 0000000000..cb8d917e3f --- /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); 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/.changeset/client-modern-era-inbound-drop.md b/.changeset/client-modern-era-inbound-drop.md new file mode 100644 index 0000000000..846bcd0581 --- /dev/null +++ b/.changeset/client-modern-era-inbound-drop.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Drop inbound JSON-RPC requests on connections that negotiated the 2026-07-28 draft revision instead of answering them: the modern era has no server→client request channel (server-initiated interactions are carried in `input_required` results), and the stdio transport forbids the +client from writing JSON-RPC responses. Dropped requests are surfaced via `onerror`. Legacy-era connections, responses, and notifications are unchanged. diff --git a/.changeset/codec-era-gates.md b/.changeset/codec-era-gates.md new file mode 100644 index 0000000000..30855b7f87 --- /dev/null +++ b/.changeset/codec-era-gates.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +Add `SdkErrorCode.MethodNotSupportedByProtocolVersion`: a typed local error raised before anything reaches the transport when a spec method is sent toward a peer whose negotiated protocol version's wire era does not define it (for example `tasks/get` toward a 2026-07-28 peer). The protocol layer now resolves a per-era wire codec from the connection's negotiated protocol version (instance state on `Client`/`Server`, with the legacy era as the pre-negotiation default) and resolves per-method schemas at dispatch time instead of registration time; an edge classification on an inbound message is validated against that instance era, and a mismatch is rejected as an entry/routing error. Behavior on existing (2025-era) connections is unchanged. diff --git a/.changeset/codec-split-wire-break.md b/.changeset/codec-split-wire-break.md new file mode 100644 index 0000000000..2a20452ab6 --- /dev/null +++ b/.changeset/codec-split-wire-break.md @@ -0,0 +1,15 @@ +--- +'@modelcontextprotocol/core': major +'@modelcontextprotocol/client': major +'@modelcontextprotocol/server': major +--- + +Split the wire layer into per-era codecs and make protocol-revision deletions physical. Deliberate wire/schema behavior changes (see docs/migration.md "Per-era wire codecs"): + +- `resultType` is no longer modeled by any neutral wire schema: `EmptyResultSchema` (strict) now rejects `{resultType}` bodies; on 2025-era connections a foreign `resultType` is stripped before validation instead of rejected; the member exists only inside the 2026-era codec, which requires it. +- `CallToolResult.content` / `ToolResultContent.content` are required at the wire boundary (`content.default([])` removed): handler results without `content` are rejected with `-32602` instead of silently defaulted, and content-less wire results fail the client parse loudly. +- Custom (3-arg) handlers now receive `_meta` minus the reserved envelope keys instead of having it deleted before params validation. +- `specTypeSchemas` re-scoped to the neutral model: result validators no longer accept `resultType`; task message-type validators and `RequestMetaEnvelope` left the public set (`SpecTypeName` narrowed). +- Role aggregate types/schemas (`ClientRequest`, `ServerResult`, …) no longer carry task vocabulary; the deprecated `Task*` types remain importable unchanged. +- Era-mismatched spec methods fail physically: inbound era-deleted methods get `-32601` even with a handler registered; outbound sends throw `SdkErrorCode.MethodNotSupportedByProtocolVersion` locally. +- Value guards (`isCallToolResult`, …) are documented as neutral-shape consumer checks, not wire validators. diff --git a/.changeset/codemod-flag-removed-task-options.md b/.changeset/codemod-flag-removed-task-options.md new file mode 100644 index 0000000000..7eec3cf127 --- /dev/null +++ b/.changeset/codemod-flag-removed-task-options.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/codemod': patch +--- + +The v1→v2 codemod no longer rewrites `taskStore`/`taskMessageQueue` McpServer constructor options into `capabilities.tasks` — that target does not exist in v2 (the experimental tasks runtime was removed, SEP-2663). The codemod now leaves the code untouched and emits an action-required diagnostic telling migrators to remove the option, matching the removal guidance already given for `experimental/tasks` imports and the migration guide. diff --git a/.changeset/create-mcp-handler.md b/.changeset/create-mcp-handler.md new file mode 100644 index 0000000000..d103fb0ac1 --- /dev/null +++ b/.changeset/create-mcp-handler.md @@ -0,0 +1,10 @@ +--- +'@modelcontextprotocol/server': minor +--- + +Add `createMcpHandler(factory, { legacy?, onerror?, responseMode? })`, an HTTP entry point that serves the 2026-07-28 draft revision per request: each envelope-carrying request is classified once, served on a fresh instance from the factory bound to the claimed revision, +and answered with a JSON body or a lazily-upgraded SSE stream. 2025-era serving is opt-in through the `legacy` slot (`'stateless'` for per-request stateless serving via the existing streamable HTTP transport, or any fetch-shaped handler for bring-your-own wiring); without +the slot the endpoint is modern-only and rejects 2025-era requests with the unsupported-protocol-version error naming its supported revisions. The handler exposes a web-standard `fetch(request, { authInfo?, parsedBody? })` face and a duck-typed `node(req, res, parsedBody?)` +face, plus `close()` for tearing down in-flight modern exchanges. Also exported: `legacyStatelessFallback` (the canonical slot value), the `PerRequestHTTPServerTransport` single-exchange transport and the `classifyInboundRequest` classifier for hand-wired compositions, and +the supporting types. `responseMode: 'json'` never streams and drops mid-call notifications (progress, logging and other related messages emitted before the result); listen-class subscription streams are always served over SSE. The entry performs no Origin/Host validation +(use the middleware packages) and no token verification — `authInfo` is pass-through and never derived from request headers. diff --git a/.changeset/deprecate-client-identity-accessors.md b/.changeset/deprecate-client-identity-accessors.md new file mode 100644 index 0000000000..8b73104076 --- /dev/null +++ b/.changeset/deprecate-client-identity-accessors.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/server': patch +--- + +Deprecate `Server.getClientCapabilities()`, `Server.getClientVersion()` and `Server.getNegotiatedProtocolVersion()` in favor of the per-request handler context: on 2026-07-28 requests the validated `_meta` envelope carries the client's identity (`ctx.mcpReq.envelope`), +and instances serving that revision through `createMcpHandler` are backfilled per request so the accessors keep answering. Behavior on 2025-era connections is unchanged; the accessors remain functional. diff --git a/.changeset/extract-task-manager.md b/.changeset/extract-task-manager.md deleted file mode 100644 index 6a72182837..0000000000 --- a/.changeset/extract-task-manager.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@modelcontextprotocol/core": minor -"@modelcontextprotocol/client": minor -"@modelcontextprotocol/server": minor ---- - -refactor: extract task orchestration from Protocol into TaskManager - -**Breaking changes:** -- `taskStore`, `taskMessageQueue`, `defaultTaskPollInterval`, and `maxTaskQueueSize` moved from `ProtocolOptions` to `capabilities.tasks` on `ClientOptions`/`ServerOptions` diff --git a/.changeset/fix-task-session-isolation.md b/.changeset/fix-task-session-isolation.md deleted file mode 100644 index 7220673374..0000000000 --- a/.changeset/fix-task-session-isolation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/core': patch ---- - -Fix InMemoryTaskStore to enforce session isolation. Previously, sessionId was accepted but ignored on all TaskStore methods, allowing any session to enumerate, read, and mutate tasks created by other sessions. The store now persists sessionId at creation time and enforces ownership on all reads and writes. diff --git a/.changeset/hide-wire-only-members.md b/.changeset/hide-wire-only-members.md new file mode 100644 index 0000000000..7d241921f5 --- /dev/null +++ b/.changeset/hide-wire-only-members.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': major +'@modelcontextprotocol/client': major +'@modelcontextprotocol/server': major +--- + +Hide wire-only protocol members from the public surface, at the type level and at runtime. `resultType` (the 2026-07-28 result discrimination field) is no longer declared on any public result type — the wire schemas keep parsing it, and the client funnel now consumes it raw-first: `'complete'` results are stripped to the public shape and any other kind (e.g. `input_required`) rejects with the new `SdkErrorCode.UnsupportedResultType` instead of masking into an empty success. The reserved `_meta` envelope keys are lifted out of inbound requests and notifications before handlers run, and the multi-round-trip retry fields (`inputResponses`, `requestState`) out of inbound requests only (the spec reserves those names on client-initiated requests; notification params keep them), so handler params keep the 2025-era shape; for requests the lifted material surfaces at `ctx.mcpReq.envelope`, `ctx.mcpReq.inputResponses`, and `ctx.mcpReq.requestState` (notifications have no ctx — their lifted envelope keys are not surfaced). High-level client/server methods now return the named public result types (`Promise` etc.). Task wire vocabulary stays importable but is `@deprecated` and excluded from the typed method maps (`RequestMethod`/`RequestTypeMap`/`ResultTypeMap`/`NotificationTypeMap`), and `callTool` is typed as plain `CallToolResult`. See docs/migration.md "Wire-only protocol members hidden from the public types". diff --git a/.changeset/missing-client-capability-error.md b/.changeset/missing-client-capability-error.md new file mode 100644 index 0000000000..9653679e05 --- /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 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/.changeset/node-forward-supported-versions.md b/.changeset/node-forward-supported-versions.md new file mode 100644 index 0000000000..413f53fde4 --- /dev/null +++ b/.changeset/node-forward-supported-versions.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/node': patch +--- + +Forward `setSupportedProtocolVersions` from `NodeStreamableHTTPServerTransport` to the wrapped Web Standard transport. Previously a server's `supportedProtocolVersions` option never reached the Node adapter's `MCP-Protocol-Version` header validation, which silently kept +validating against the default version list. diff --git a/.changeset/origin-validation-middleware.md b/.changeset/origin-validation-middleware.md new file mode 100644 index 0000000000..7f484d7412 --- /dev/null +++ b/.changeset/origin-validation-middleware.md @@ -0,0 +1,12 @@ +--- +'@modelcontextprotocol/server': minor +'@modelcontextprotocol/express': minor +'@modelcontextprotocol/hono': minor +'@modelcontextprotocol/fastify': minor +'@modelcontextprotocol/node': minor +--- + +Add Origin header validation alongside the existing Host header validation. The server package gains framework-agnostic helpers (`validateOriginHeader`, `localhostAllowedOrigins`, `originValidationResponse`); the Express, Hono and Fastify adapters gain `originValidation` / +`localhostOriginValidation` middleware and a new `allowedOrigins` option on their app factories, which now arm Origin validation by default for localhost-class binds (mirroring the Host validation ladder; the 0.0.0.0-without-allowlist warning is unchanged). Requests +without an `Origin` header pass — non-browser MCP clients are unaffected — while a present `Origin` that is not allowed or cannot be parsed (including the opaque `null` origin) is rejected with `403`. The Node adapter ships `hostHeaderValidation` / `originValidation` +request guards for plain `node:http` servers, which previously had no validation helpers. diff --git a/.changeset/pin-modern-rejection-codes.md b/.changeset/pin-modern-rejection-codes.md new file mode 100644 index 0000000000..f54bdd9785 --- /dev/null +++ b/.changeset/pin-modern-rejection-codes.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/server': patch +--- + +Pin the modern (2026-07-28) HTTP serving path's rejection codes to the assignments the published conformance suite asserts: a header/body cross-check mismatch (`MCP-Protocol-Version` or `Mcp-Method` disagreeing with the request body) is now rejected with `-32001` (HeaderMismatch), and a request whose protocol-version header names a modern revision but whose body is missing the `_meta` envelope (or its required protocol-version key) is rejected with `-32602` invalid params naming the missing key(s). Both keep HTTP 400. These cells previously emitted a provisional `-32004` while the upstream error-code discussion was open. The envelope-less rejection on a modern-only endpoint (`-32004` with the supported-versions list), the 2025-era serving paths, and the client-side probe handling are unchanged. diff --git a/.changeset/server-era-support.md b/.changeset/server-era-support.md new file mode 100644 index 0000000000..c805112f56 --- /dev/null +++ b/.changeset/server-era-support.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/server': minor +--- + +Add `ServerOptions.eraSupport: 'legacy' | 'dual-era' | 'modern'`, the opt-in for serving the 2026-07-28 draft revision on long-lived connections such as stdio. The default is `'legacy'` and preserves today's behavior exactly: nothing 2026-era is registered or advertised, and 2025 +wire behavior is unchanged by the upgrade. `'dual-era'` serves both protocol eras on the same connection, selecting the era per message (`initialize`-negotiated 2025 traffic as before, per-request `_meta` envelope traffic — including `server/discover` — on the modern era), while +methods that exist in only one era stay invisible to the other. `'modern'` is strict 2026-only: requests without the envelope (including `initialize`) are answered with the unsupported-protocol-version error naming the supported revisions. A 2026-era revision in +`supportedProtocolVersions` now requires declaring `eraSupport` (`'dual-era'` or `'modern'`); on a default `'legacy'` instance it throws a `TypeError` at construction instead of silently installing the `server/discover` handler. On dual-era instances the deprecated +client-identity accessors keep their `initialize`-scoped semantics and are never backfilled from 2026-era requests; handlers read per-request identity from `ctx.mcpReq.envelope`. diff --git a/.changeset/spec-corpus-and-leak-net.md b/.changeset/spec-corpus-and-leak-net.md new file mode 100644 index 0000000000..017ecd1501 --- /dev/null +++ b/.changeset/spec-corpus-and-leak-net.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +Test-only hardening, no runtime changes: a spec example corpus harness (the draft revision's 86 example directories vendored from the specification repository plus a frozen hand-built 2025-11-25 corpus, with rejection-side fixtures routed through real dispatch), a cross-bundle typed-error recognition guard, and extended end-to-end draft-vocabulary leak coverage for hosted transports, SSE streams, and compatibility fallback paths. diff --git a/.changeset/spec-types-2026-repin.md b/.changeset/spec-types-2026-repin.md new file mode 100644 index 0000000000..dbf757cd4e --- /dev/null +++ b/.changeset/spec-types-2026-repin.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +Internal: regenerate the 2026-07-28 spec reference types from the latest draft schema (`DiscoverResult` now extends `CacheableResult`; `ElicitationCompleteNotificationParams` extracted as a named interface) and document the anchor lifecycle policy. Released-revision spec-type generation is now pinned to a fixed spec commit; draft anchors keep floating via the nightly refresh PRs. No public API or runtime behavior changes. diff --git a/.changeset/wire-server-discover.md b/.changeset/wire-server-discover.md new file mode 100644 index 0000000000..b83b860e5e --- /dev/null +++ b/.changeset/wire-server-discover.md @@ -0,0 +1,11 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/server': minor +'@modelcontextprotocol/client': minor +--- + +Wire `server/discover` (protocol revision 2026-07-28) into the typed request funnel and serve it era-aware. The request joins `ClientRequestSchema`/`ServerResultSchema`/`ResultTypeMap` (per-era availability stays with the wire registries: only the 2026-era registry serves +it), and `Client.discover()` issues it as a typed request on 2026-era connections. A `Server` whose `supportedProtocolVersions` list carries a modern (2026-07-28+) revision installs the `server/discover` handler, advertising ONLY its modern revisions and excluding the +listChanged/subscribe-class capabilities until the `subscriptions/listen` flow ships; servers with today's default list are unchanged and keep answering `-32601`. The `initialize` handshake is now era-aware in the other direction: its accept check and counter-offer consult +only the legacy subset of the supported versions — a 2026-era revision is never negotiated via `initialize` — so a 2025-era client can never be offered a 2026 version string; with the default list this is byte-identical to previous behavior. Serving the 2026 revision to +ordinary HTTP/stdio traffic arrives with an upcoming server-side entry point: today the negotiation surface is client-side, and `mode: 'auto'` falls back cleanly against current SDK servers. diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 0deab54482..dd01a74c10 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -2,7 +2,7 @@ name: Conformance Tests on: push: - branches: [main] + branches: [main, v2-2026-07-28] pull_request: workflow_dispatch: @@ -30,6 +30,7 @@ jobs: - run: pnpm install - run: pnpm run build:all - run: pnpm run test:conformance:client:all + - run: pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client:2026 server-conformance: runs-on: ubuntu-latest @@ -48,3 +49,4 @@ jobs: - run: pnpm run build:all - run: pnpm run test:conformance:server - run: pnpm run test:conformance:server:draft + - run: pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:2026 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 44852a93d6..5686454414 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,6 +2,7 @@ on: push: branches: - main + - v2-2026-07-28 pull_request: workflow_dispatch: diff --git a/.github/workflows/update-spec-types.yml b/.github/workflows/update-spec-types.yml index 482fb04213..41c1303b0a 100644 --- a/.github/workflows/update-spec-types.yml +++ b/.github/workflows/update-spec-types.yml @@ -1,3 +1,14 @@ +# Nightly refresh of the draft-tracking spec anchor (2026-07-28). +# +# Anchor lifecycle (see packages/core/src/types/README.md for the full policy): +# - Draft anchors float: this job regenerates the draft-tracking anchor from the +# latest upstream draft schema and, on drift, opens a refresh PR for review. +# It only ever proposes — it never merges. +# - Released anchors are frozen: generation for released revisions is pinned in +# scripts/fetch-spec-types.ts (RELEASED_REVISION_PINS) and is not refreshed by +# this job. Repinning a released revision — including the freeze of a newly +# published revision, when its schema moves out of schema/draft/ — must land +# in the same commit that retargets this workflow. name: Update Spec Types on: diff --git a/.prettierignore b/.prettierignore index d2fb242b9d..0ece978310 100644 --- a/.prettierignore +++ b/.prettierignore @@ -13,6 +13,14 @@ pnpm-lock.yaml **/src/types/spec.types.2025-11-25.ts **/src/types/spec.types.2026-07-28.ts +# Spec example corpora: vendored verbatim from the spec repository +# (fetch:spec-examples) or hand-built and frozen - byte-faithful artifacts. +packages/core/test/corpus/fixtures/ + +# Schema twins: raw upstream schema.json bytes (fetch:schema-twins), locked to +# manifest.json by sha256 in schemaTwinConformance - reformatting breaks the lock. +packages/core/test/corpus/schema-twins/ + # Batch test cloned repos and results packages/codemod/batch-test/repos packages/codemod/batch-test/results diff --git a/docs/behavior-surface-pins.md b/docs/behavior-surface-pins.md new file mode 100644 index 0000000000..199712e102 --- /dev/null +++ b/docs/behavior-surface-pins.md @@ -0,0 +1,49 @@ +# Behavior-surface pins + +Some tests in this repo are **pins**: they assert the exact current value of a +wire- or consumer-visible behavior — an error code, a schema boundary, an +export map, the stdio env safelist — rather than checking that a feature +works. Their job is to distinguish a deliberate surface change from an +accidental one: the regular suite stays green through either; a pin goes red +through both. + +## When a pin goes red on your change + +A red pin does **not** mean the change is forbidden. It means the change is +surface-visible and must be deliberate: + +1. Confirm the change is intended. If it isn't, the pin just caught an + accidental break. +2. Update the pin in the same PR. +3. Add a changeset if the surface is consumer-facing. +4. Update `docs/migration.md` / `docs/migration-SKILL.md` where consumer-facing. + +Never weaken a pin (loosen an exact match, delete an assertion) just to make +CI pass — that reopens the silent-drift hole the pin exists to close. + +## Where pins live + +| Surface | File | +| --- | --- | +| Wire error-code tables, error classes, version constants | `packages/core/test/types/errorSurfacePins.test.ts` | +| Schema strict/strip/loose boundaries, key existence | `packages/core/test/types/schemaBoundaryPins.test.ts` | +| Published package set, export maps, ESM-only topology | `packages/core/test/packageTopologyPins.test.ts` | +| stdio environment-inheritance safelist | `packages/client/test/client/stdioEnvPins.test.ts` | + +## Writing a new pin + +- The expectation side must be a literal frozen in the test, never a value + imported from src. Comparing a source constant against itself pins nothing. +- Mutation-check it once before landing: flip the source behavior locally and + confirm the pin actually goes red. A pin that stays green under the drift it + claims to guard is worse than no pin. +- Pin behavior a deployed peer or consumer can observe. Internal details that + are invisible across the wire and the public API don't need pins. +- Don't pin a known bug to make it load-bearing — file an issue instead. + +## History + +The original, much broader inventory was developed against v1.x in #2258 and +#2262 (closed unmerged). This sweep ports only the boundary surfaces above; +see those PRs for the fuller exploration and the reasoning behind what was +left out. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index aef327622c..311ae8d956 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -501,7 +501,34 @@ The 2025-11 task side-channel through `Protocol` is removed (was always `@experi `TaskStore` / `InMemoryTaskStore` / `CreateTaskOptions` / `isTerminal` (storage layer) are also removed; they will return with the SEP-2663 server-directed plugin. -NOT removed (wire surface, kept for 2025-11-25 interop): task Zod schemas + inferred types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, `TaskAugmentedRequestParams`), task members of the request/result/notification unions, the `tasks` capability key, `isTaskAugmentedRequestParams`, `RELATED_TASK_META_KEY`. Inbound `tasks/*` requests → `-32601`. +NOT removed (wire surface, kept for 2025-11-25 interop, now `@deprecated`): task Zod schemas + inferred types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, `TaskAugmentedRequestParams`), task members of the request/result/notification union types, the `tasks` capability key, `isTaskAugmentedRequestParams`, `RELATED_TASK_META_KEY`. Inbound `tasks/*` requests → `-32601`. + +Task methods are excluded from the typed method maps: `RequestMethod`/`RequestTypeMap`/`ResultTypeMap` have no `tasks/*` entries and `NotificationMethod`/`NotificationTypeMap` have no `notifications/tasks/status`, so the method-keyed overloads of `request()`, `ctx.mcpReq.send()`, `setRequestHandler()`, `setNotificationHandler()` reject task methods at compile time. Mechanical fix where task interop is genuinely required: pass an explicit schema (`request({ method: 'tasks/get', params }, GetTaskResultSchema)`-style custom-method form). `ResultTypeMap['tools/call']` is plain `CallToolResult` (no `| CreateTaskResult`); same for `sampling/createMessage` and `elicitation/create`. + +## 12b. Wire-only members hidden from public types + +`resultType` (2026-07-28 result discrimination) is no longer declared on any public result type; the SDK parses and consumes it internally. The reserved `_meta` envelope keys (`io.modelcontextprotocol/{protocolVersion,clientInfo,clientCapabilities,logLevel}`) and retry fields (`inputResponses`, `requestState`) appear in no public params/result type. `RequestMetaEnvelope` and the `*_META_KEY` constants remain exported. + +| Pattern in v2-alpha code | Mechanical fix | +| ------------------------------------- | --------------------------------------------------------------------------------- | +| `result.resultType` (typed read) | delete the read — the SDK consumes the field; results are complete when delivered | +| `Result['resultType']` type reference | remove; the member is no longer declared | +| return-type capture of `callTool` etc. | use the named public types (`CallToolResult`, `ListToolsResult`, …) | + +Runtime counterpart: inbound reserved envelope keys are lifted out of `params._meta` before handlers run — on requests they are readable at `ctx.mcpReq.envelope` (typed `Partial`, keys present only as received); on notifications there is no ctx, so the lifted envelope keys are dropped and NOT surfaced anywhere. Retry fields (`inputResponses`/`requestState`) lift from REQUEST top-level params only, to `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`; notification params are never touched. On a 2026-era exchange a response carrying a non-`complete` `resultType` rejects with `SdkError` code `UNSUPPORTED_RESULT_TYPE` (kind in `error.data.resultType`), while on a 2025-era connection a foreign `resultType` is stripped before validation; the serving wire era is the instance's negotiated protocol version (connection state), and `MessageExtraInfo.classification` is only validated against it at dispatch (a mismatch is rejected as an entry/routing error). Collision note for 2025-era peers: 2025-11-25 reserves the `io.modelcontextprotocol/` `_meta` prefix but NOT the bare names `inputResponses`/`requestState`, so a 2025 peer's custom-method request using those names as ordinary params has them lifted out of `request.params` (recoverable via ctx; everything else passes through untouched). + +## 12c. Per-era wire codecs (physical deletions + stricter wire schemas) + +The wire layer is split into per-era codecs (2025-era = 2024-10-07 … 2025-11-25; 2026-era = 2026-07-28). Era-mismatched spec methods fail physically: inbound -> `-32601` even with a handler registered; outbound -> `SdkError` code `METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION` before the transport. + +| Pattern in v2-alpha code | Mechanical fix | +| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | +| tool handler returns without `content` | add `content: []` (or real content) — results without it are rejected `-32602`, no longer defaulted | +| parsing wire bytes with `EmptyResultSchema` that may carry `resultType` | strip `resultType` first (the schema now rejects it as an unknown key) | +| strict custom-handler params schema (3-arg `setRequestHandler`/`setNotification…`) | add optional `_meta` to the schema (or strip it) — `_meta` is now passed through minus reserved keys | +| `specTypeSchemas`/`SpecTypeName` references to task message types or `RequestMetaEnvelope` | remove — these validators left the public set (types remain importable) | +| `ClientRequest`/`ServerResult`/… aggregate types expected to include task members | use the individual deprecated `Task*` types — role aggregates are now the neutral (task-free) sets | +| relying on `isCallToolResult` to reject wire-only members | guards validate neutral shapes (loose passthrough); validate raw wire traffic with a transport-level parse | ## 13. Behavioral Changes @@ -516,6 +543,22 @@ No code changes required; these are wire-behavior notes: - Resumability behavior (SSE priming events, `closeSSEStream` / `closeStandaloneSSEStream` callbacks) is only enabled for protocol versions in the transport's supported-versions list that are `>= 2025-11-25`. Unknown future version strings in an `initialize` request body no longer enable it. Behavior for all currently supported protocol versions is unchanged. - Session-ID mismatch still responds `404 Not Found` with JSON-RPC error code `-32001` (`Session not found`), unchanged from v1. This `-32001` usage is an SDK convention, not a spec-assigned code, and may be re-derived as 2026 protocol revision error handling is adopted — migrated client code should key off the HTTP `404` status, not the `-32001` code. +### Server (deprecated accessors and app-factory Origin validation) + +These can require code changes: + +- `Server.getClientCapabilities()`, `getClientVersion()` and `getNegotiatedProtocolVersion()` are deprecated but functional: prefer the per-request context (`ctx.mcpReq.envelope`) on 2026-07-28 requests. No mechanical change required yet; plan the move before the deprecations are removed. +- `createMcpExpressApp()` / `createMcpHonoApp()` / `createMcpFastifyApp()` with a localhost-class `host` now also validate the `Origin` header by default (requests without an `Origin` header are unaffected). Browser-served clients on a non-localhost origin need `allowedOrigins: [...]`, which replaces the default localhost allowlist — Origin validation cannot be disabled for localhost-class binds. + +### Server (stdio / long-lived connections) + +- `ServerOptions.eraSupport?: 'legacy' | 'dual-era' | 'modern'` declares which protocol eras a hand-constructed `Server`/`McpServer` serves on its long-lived connection. Default `'legacy'` = today's behavior, byte-identical: do not add the option during a mechanical migration. +- Serving the 2026-07-28 draft revision on stdio is the explicit opt-in `new McpServer(info, { eraSupport: 'dual-era' })` with an unchanged `connect(new StdioServerTransport())`. `'modern'` is strict 2026-only (envelope-less requests, including `initialize`, get the + unsupported-protocol-version error). +- A 2026-era revision in `supportedProtocolVersions` now requires `eraSupport: 'dual-era' | 'modern'`; on a default (`'legacy'`) instance it throws a `TypeError` at construction (previously it silently installed the `server/discover` handler). +- On dual-era instances `getClientCapabilities()` / `getClientVersion()` / `getNegotiatedProtocolVersion()` keep `initialize`-scoped semantics and are never backfilled from 2026-era requests; handlers read per-request identity from `ctx.mcpReq.envelope`. +- A client whose connection negotiated a modern era drops inbound server→client JSON-RPC requests (the 2026 era has no such channel) instead of answering them; legacy-era connections are unchanged. + ## 14. Runtime-Specific JSON Schema Validators (Enhancement) The SDK now auto-selects the appropriate JSON Schema validator based on runtime: diff --git a/docs/migration.md b/docs/migration.md index 576f6c5ce4..70ef7df0e8 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -902,12 +902,260 @@ The 2025-11 experimental tasks side-channel woven through `Protocol` has been re **Also removed:** the storage layer (`TaskStore`, `InMemoryTaskStore`, `CreateTaskOptions`, `isTerminal`). It will return as part of the SEP-2663 server-directed plugin in a follow-up. -**Wire types remain.** The task wire surface defined by the 2025-11-25 protocol revision is still exported, for interoperability with peers on that revision: the task Zod schemas and their inferred types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `GetTaskPayload*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, `TaskAugmentedRequestParams`), the task members of the request/result/notification unions, the `tasks` capability key, the `isTaskAugmentedRequestParams` guard, and `RELATED_TASK_META_KEY`. Only the behavior is gone: servers built on this SDK do not advertise the `tasks` capability, and inbound `tasks/*` requests receive a standard `-32601` (method not found) error. +**Wire types remain, as deprecated vocabulary.** The task wire surface defined by the 2025-11-25 protocol revision is still exported, for interoperability with peers on that revision: the task Zod schemas and their inferred types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `GetTaskPayload*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, `TaskAugmentedRequestParams`), the task members of the request/result/notification union types, the `tasks` capability key, the `isTaskAugmentedRequestParams` guard, and `RELATED_TASK_META_KEY`. These exports are now marked `@deprecated` (importable wire vocabulary only; removable at the major version that drops 2025-era support), and the typed method surface no longer offers task methods: `RequestMethod`/`RequestTypeMap`/`ResultTypeMap`/`NotificationTypeMap` exclude `tasks/*` and `notifications/tasks/status`, so the method-keyed overloads of `request()`, `ctx.mcpReq.send()`, `setRequestHandler()`, and `setNotificationHandler()` do not accept them (the explicit-schema overloads still work for custom interop). The method-keyed result types are narrowed to match: `ResultTypeMap['tools/call']` is plain `CallToolResult` (no `| CreateTaskResult`), and likewise `sampling/createMessage` and `elicitation/create` lose their task-result union members — the runtime result validation uses the same plain schemas, so a task-shaped response body to one of these methods fails as a local `INVALID_RESULT` error where the result schema rejects it rather than parsing into a mis-typed success. Only the behavior is gone: servers built on this SDK do not advertise the `tasks` capability, and inbound `tasks/*` requests receive a standard `-32601` (method not found) error. There is no migration path for the removed surface; it was always `@experimental`. Task support is planned to return as an opt-in extension plugin per SEP-2663. +### Wire-only protocol members hidden from the public types + +The protocol revision 2026-07-28 introduces wire-level bookkeeping that the SDK handles internally and that never needs to reach application code: the `resultType` result discrimination field, the reserved per-request `_meta` envelope keys (`io.modelcontextprotocol/protocolVersion`, `io.modelcontextprotocol/clientInfo`, `io.modelcontextprotocol/clientCapabilities`, `io.modelcontextprotocol/logLevel`), and the multi-round-trip retry fields (`inputResponses`, `requestState`). The public TypeScript surface no longer declares these members: + +- **`resultType` is gone from every public result type** (`Result`, `CallToolResult`, `GetPromptResult`, …, and the `result` member of `JSONRPCResultResponse`). The wire schemas keep parsing it, and the protocol layer consumes it before results reach your code. If you previously read `result.resultType` (it was always `undefined` from conforming 2025-era peers), drop the read — the SDK now owns that field. +- **High-level methods return the named public types.** `client.callTool()` returns `Promise`, `client.listTools()` returns `Promise`, and so on (previously these returned structurally inferred schema types that exposed `resultType?`). Handler return positions are unaffected: results you build keep type-checking, and unknown members still pass through the loose index signature. +- **The reserved envelope keys and retry fields never appear in a public params/result type.** The `RequestMetaEnvelope` type and the four `*_META_KEY` constants stay exported — they document the wire names and type the context surfacing channel (see below). + +The protocol layer enforces the same boundary at runtime: + +- **Envelope lift.** On inbound requests and notifications, the reserved `io.modelcontextprotocol/*` envelope keys are lifted out of `params._meta` before handlers run, so handler params are byte-equal to the 2025-era shape under 2026-era traffic. For requests the envelope is readable at `ctx.mcpReq.envelope` (typed `Partial` — only the keys the request actually carried are present); for notifications there is no per-message context, so lifted envelope keys are dropped, not surfaced. On requests only, the multi-round-trip retry fields are likewise lifted out of top-level params and surfaced verbatim at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`; notification params are never touched. +- **What this means for 2025-era peers.** The `_meta` side of the lift is invisible to conforming 2025-era traffic: the `io.modelcontextprotocol/` prefix is reserved in 2025-11-25 too, so a conforming 2025 peer never puts application data under those keys. The retry-field lift is the one collision to know about: 2025-11-25 does not reserve the bare names `inputResponses`/`requestState`, so a 2025 peer's **custom-method request** that happens to use them as ordinary top-level params will have them lifted out of the handler's view (still readable at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`, just no longer in `request.params`). Spec-method requests are unaffected (no 2025 spec method defines params with those names), as are all notifications. +- **Raw-first result discrimination.** The client funnel inspects a response's raw `resultType` before schema validation: `'complete'` is consumed (stripped) and the result parses as the public shape; any other kind (e.g. `input_required`) rejects with a typed local error — `SdkError` with the new code `SdkErrorCode.UnsupportedResultType` and the kind in `error.data.resultType` — instead of being masked into a hollow success by tolerant result schemas. Full multi-round-trip support will replace that error arm. +- **`MessageExtraInfo.classification`** is an optional carrier (`{ era, revision?, envelope? }`) for transports that classify inbound messages at the edge. The wire era itself is connection state (the negotiated protocol version held by the `Client`/`Server` instance); dispatch validates a classified message against that era and treats a mismatch as an entry/routing error (see the next section). + +**Before (v2 alpha):** + +```typescript +const result = await client.callTool({ name: 'echo', arguments: {} }); +// result.resultType was declared as `string | undefined` and always undefined +if (result.resultType === undefined || result.resultType === 'complete') { + console.log(result.content); +} +``` + +**After:** + +```typescript +const result = await client.callTool({ name: 'echo', arguments: {} }); +// resultType is wire-level bookkeeping the SDK consumes; just use the result +console.log(result.content); +``` + +### Per-era wire codecs: physical deletions and stricter wire schemas + +The wire layer is now split into per-revision codecs inside the (private, bundled) core: one codec serves every 2025-era protocol version (2024-10-07 … 2025-11-25) and one serves 2026-07-28. The codec is selected by the negotiated protocol version, which is connection state on the `Client`/`Server` instance: the client stores it when its initialize handshake completes, the server stores it when it answers `initialize`, and instances with no negotiated version default to the 2025 era (with the pre-negotiation lifecycle messages routed by method: `initialize`/`notifications/initialized` are 2025-era vocabulary, `server/discover` is 2026-era vocabulary). An edge classification (`MessageExtraInfo.classification`) no longer switches the era per message — it is validated against the instance era, and a mismatch is rejected as an entry/routing error (`-32004 Unsupported protocol version` for requests, a drop plus `onerror` for notifications). Methods deleted by a protocol revision are now PHYSICALLY absent from that era's registry: an inbound `tasks/get` on a 2026-era connection gets `-32601` even if a handler is registered, and sending an era-mismatched spec method (for example `server/discover` toward a 2025-era peer, or any `tasks/*` method toward a 2026-era peer) throws a typed local error — `SdkError` with the new code `SdkErrorCode.MethodNotSupportedByProtocolVersion` — before anything reaches the transport. + +Alongside the split, the following deliberate wire-behavior changes ship (each is invisible to conforming peers but observable to direct schema consumers and misbehaving peers): + +- **`resultType` is no longer modeled by any neutral wire schema.** The base `ResultSchema` (and every result schema derived from it) no longer declares the optional `resultType` member. Consequences: + - `EmptyResultSchema` (strict) now REJECTS `{resultType: ...}` bodies where it previously accepted them. On the protocol path nothing changes for conforming peers: the 2026-era codec consumes the field, and the 2025-era codec strips a foreign `resultType` before validation (tolerate-and-drop — a 2025-era peer that sends it is misbehaving). + - On a 2025-era connection, a response carrying a non-`'complete'` `resultType` is no longer rejected with `UnsupportedResultType`: the field is foreign vocabulary on that era and is stripped before validation (the result then passes or fails validation on its actual content, loudly). On a 2026-era exchange the discrimination is stricter than before: `resultType` is REQUIRED, an absent value is a spec violation surfaced as a typed error, and `input_required` / unknown kinds reject with `UnsupportedResultType` / `InvalidResult`. +- **`CallToolResult.content` and `ToolResultContent.content` are required at the wire boundary.** The `content.default([])` affordance was removed (it could silently convert unrecognized result shapes into hollow `{content: []}` successes). Tool handlers MUST include `content` in their results (the TypeScript surface always required it — `content: []` is fine); a handler result without it is now rejected with `-32602 Invalid tools/call result` instead of being silently defaulted, and a content-less wire result fails the client-side parse loudly. +- **Custom (3-arg) handlers receive `_meta`.** `setRequestHandler(method, {params}, handler)` / `setNotificationHandler(method, {params}, handler)` used to DELETE `params._meta` before validating with your schema. They now pass it through minus the reserved `io.modelcontextprotocol/*` envelope keys (which the protocol layer lifts out), making custom methods consistent with spec methods. If your params schema is strict (rejects unknown keys), add an optional `_meta` member or strip it yourself. +- **`specTypeSchemas` validate the neutral model.** Result entries no longer accept/declare `resultType`; the validators for the 2025-only task message types (`Task`, `TaskStatus`, `GetTask*`, `ListTasks*`, `CancelTask*`, `CreateTaskResult`, `TaskStatusNotification*`, `TaskCreationParams`) and for `RequestMetaEnvelope` left the public set (`SpecTypeName` narrowed accordingly). Per-revision wire validators are planned to return as versioned `zod-schemas/` exports. +- **Role aggregate types no longer carry task vocabulary.** `ClientRequest`, `ClientResult`, `ClientNotification`, `ServerRequest`, `ServerResult`, and `ServerNotification` (and their union schemas) are now the neutral message sets; the task members moved into the internal 2025-era wire module. The individual `Task*` types remain importable (deprecated) exactly as before. +- **Value guards are consumer-side checks, not wire validators.** `isCallToolResult` and friends now validate the neutral shapes; a raw wire object carrying `resultType` still passes them through the loose index signature. Validate raw wire traffic with a transport-level parse, not the guards. + +**Before:** + +```typescript +// A handler omitting content was silently defaulted on the wire: +server.setRequestHandler('tools/call', async () => { + return { structuredContent: { ok: true } } as CallToolResult; // wire: content [] +}); + +// Custom handlers never saw _meta: +protocol.setRequestHandler('acme/op', { params: z.strictObject({ x: z.number() }) }, async params => ({})); +``` + +**After:** + +```typescript +// content is required (as the spec always said): +server.setRequestHandler('tools/call', async () => { + return { content: [], structuredContent: { ok: true } }; +}); + +// Custom handlers receive _meta minus the reserved envelope keys: +protocol.setRequestHandler( + 'acme/op', + { params: z.strictObject({ x: z.number(), _meta: z.record(z.string(), z.unknown()).optional() }) }, + async params => ({}) +); +``` + ## Enhancements +### Opt-in protocol version negotiation (2026-07-28 draft) + +The client can now negotiate the protocol era at connect time. This is **opt-in**: if you do nothing, `connect()` performs exactly the same 2025 `initialize` handshake as before, byte for byte. + +```typescript +import { Client } from '@modelcontextprotocol/client'; + +// Auto-negotiate: try the 2026-07-28 draft revision, fall back to the 2025 +// handshake automatically when the server is a 2025-era deployment. +const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' } } +); +await client.connect(transport); + +client.getNegotiatedProtocolVersion(); // e.g. '2026-07-28' or '2025-11-25' +``` + +How the modes behave: + +- **absent / `mode: 'legacy'`** (default): today's behavior, unchanged. No probe, no new headers. +- **`mode: 'auto'`**: `connect()` first sends a single `server/discover` probe. A modern server answers it and no `initialize` is sent; a 2025-era server rejects it (deployed servers answer fast, e.g. `-32601` or a `400`), and the client falls back to the plain legacy + handshake **on the same connection** — byte-equivalent to a 2025 client, including the `initialize` body version and with zero 2026 headers. The probe costs one round trip against an old server and nothing else. +- **`mode: { pin: '2026-07-28' }`**: modern era at exactly that revision. No fallback — if the server does not offer the pinned version, `connect()` rejects with a typed error. Use `pin` where a silent downgrade would be worse than an error (tests, CI, servers you control). + +Failure semantics under `'auto'` are deliberately conservative but never silent about infrastructure problems: anything the probe does not positively recognize as modern falls back to the legacy era — provided the supported-versions list still contains a 2025-era +revision; with a modern-only list there is nothing to fall back to and `connect()` rejects with the typed negotiation error instead — while a network outage rejects with a typed connect error (`SdkError` with `EraNegotiationFailed`). A probe timeout is transport-aware, following the specification's backward-compatibility rules: on **stdio**, a server that does not answer the probe within the timeout is treated as a legacy server (some legacy servers never respond to unknown +pre-`initialize` requests at all) and the client falls back to `initialize` on the same stream; on **HTTP**, where a deployed server answers and silence means an outage, the timeout rejects with a typed `RequestTimeout` error — a dead HTTP server is never misreported as a +legacy server. One browser-specific exception: an opaque CORS/preflight `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly have CORS allow-lists that predate the 2026 headers and the legacy handshake sends none of them. + +Probe policy is configured under `versionNegotiation.probe`: + +```typescript +versionNegotiation: { + mode: 'auto', + probe: { + timeoutMs: 10_000 // default: the standard request timeout + } +} +``` + +On the server side, a `Server`/`McpServer` serves `server/discover` (advertising only its modern revisions) when it declares modern-era support via the `eraSupport` option (see the stdio section below); servers constructed without it are byte-identical to before (they keep +answering `-32601`, and the `initialize` handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the +next section; serving it on stdio (and other long-lived connections) is the `eraSupport` server option described after that. The client can also issue the request directly via `client.discover()` on a 2026-era connection — a full typed round trip needs each request to carry the per-request `_meta` envelope (the negotiation probe +already does; automatic envelope emission for every request is a client-side follow-up) — while on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that protocol revision. + +### Serving the 2026-07-28 draft revision over HTTP: `createMcpHandler` + +The server package now ships an HTTP entry point that serves the 2026-07-28 draft revision per request, with 2025-era serving available as an **opt-in** slot: + +```typescript +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; + +const handler = createMcpHandler( + ctx => { + const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + // register tools/resources/prompts once — the same factory backs both eras + return server; + }, + { legacy: 'stateless' } +); + +// Web-standard runtimes (Cloudflare Workers, Deno, Bun, Hono): +// handler.fetch(request) +// Node frameworks (Express, Fastify, plain node:http): +// handler.node(req, res, req.body) +``` + +How the `legacy` slot behaves: + +- **omitted** — modern-only strict. 2026-07-28 (per-request `_meta` envelope) requests are served; 2025-era requests are rejected with `-32004` naming the supported revisions, and 2025-era notifications are acknowledged with `202` and dropped. **There is no silent 2025 + serving without the slot.** +- **`legacy: 'stateless'`** — 2025-era traffic is additionally served per request through the established stateless idiom: a fresh instance from the same factory and a streamable HTTP transport constructed with only `sessionIdGenerator: undefined`. The exported + `legacyStatelessFallback(factory)` is the same handler as a standalone value. +- **`legacy: `** — bring your own legacy serving (for example an existing sessionful `WebStandardStreamableHTTPServerTransport` wiring). Requests are handed to it untouched and its lifecycle stays yours. + +The optional `responseMode` controls how modern request exchanges are answered: `'auto'` (default) returns a single JSON body and lazily upgrades to an SSE stream when the handler emits a related message before its result; `'sse'` always streams; **`'json'` never streams +and DROPS mid-call notifications** (progress, logging, and any other related message emitted before the result) — only the terminal result is delivered. Subscription (listen-class) streams are always served over SSE regardless of the setting. `onerror` receives +out-of-band errors and rejected requests for logging. + +The entry performs no Origin/Host validation (see the origin-validation middleware below) and no token verification: `authInfo` passed to `handler.fetch(request, { authInfo })` / attached as `req.auth` on the Node face is forwarded to handlers as-is and never derived from +request headers. Power users who want to compose routing themselves can use the exported `classifyInboundRequest` and `PerRequestHTTPServerTransport` building blocks directly; the handler faces are bound properties, so they can be detached and passed around +(`const { fetch } = handler`). + +### Serving the 2026-07-28 draft revision on stdio: `eraSupport` + +A hand-constructed `Server`/`McpServer` — the shape every stdio server has — now takes an `eraSupport` option declaring which protocol eras it serves on its long-lived connection. **The default is `'legacy'`: if you do nothing, your server keeps speaking exactly the +2025-era protocol it was written for** — the `initialize` handshake, the same wire bytes, no `server/discover`, nothing new advertised — and upgrading the SDK changes nothing about what it puts on the wire. + +Serving the 2026-07-28 draft revision is one explicit option; the transport stays unchanged: + +```typescript +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; + +const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { eraSupport: 'dual-era' }); +await server.connect(new StdioServerTransport()); +``` + +What the values mean: + +- **`'legacy'` (default)** — today's behavior, unchanged: 2025-era serving negotiated via `initialize`. `server/discover` is not registered or advertised. Declaring a 2026-era revision in `supportedProtocolVersions` without changing `eraSupport` is now a construction-time + `TypeError` (previously it silently installed the discover handler) — serving the new revision is always an explicit declaration, never a side effect of a version list. +- **`'dual-era'`** — both eras on the same connection, selected per message: plain 2025 clients keep using `initialize` and are served exactly as before, while 2026-capable clients negotiate via `server/discover` on the same pipe and every request carrying the per-request + `_meta` envelope is served on the modern era. Methods that exist in only one era stay invisible to the other: a 2025-era client asking for a 2026-only method (such as `server/discover` without an envelope) gets the same plain `-32601` a 2025 server would send, and a + 2026-era request for a removed method (such as `logging/setLevel`) gets `-32601` too. +- **`'modern'`** — strict 2026-only: requests without the per-request envelope (including `initialize`) are answered with the unsupported-protocol-version error naming the supported revisions; legacy-era notifications are dropped. + +Declaring `'dual-era'` or `'modern'` automatically adds the SDK's supported modern revisions to `supportedProtocolVersions`, and `'modern'` serves only those: a strict instance's supported list (what `server/discover` advertises and version-mismatch errors name) is modern-only. + +Directionality follows the era of the traffic: the 2026-07-28 revision has no server→client JSON-RPC request channel, so a `'modern'` instance cannot emit `sampling`/`elicitation`/`roots` wire requests (they fail locally with a typed error), while a `'dual-era'` instance +can still send them to the 2025-era clients it serves via `initialize`. On a `'dual-era'` instance the same local typed error applies per request: a handler that is serving a 2026-era request cannot send server→client requests through its request context +(`ctx.mcpReq.send`, `ctx.mcpReq.elicitInput`, `ctx.mcpReq.requestSampling`) — only handlers serving 2025-era requests can. Symmetrically, a client whose connection negotiated a modern era drops inbound JSON-RPC requests instead of answering them. + +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 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 + +`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 +handlers as `ctx.mcpReq.envelope`; instances serving that revision through `createMcpHandler` are backfilled per request, so existing code that calls the accessors keeps working on both eras. On 2025-era connections the accessors keep returning the `initialize`-scoped +values, as before. + +On a long-lived dual-era instance (`eraSupport: 'dual-era'`, e.g. a stdio server) the accessors are **not** backfilled from modern requests: 2025-era and 2026-era messages interleave on one connection, so instance-level backfill would race. There the accessors keep their +`initialize`-scoped semantics — they reflect what the legacy handshake negotiated (or `undefined` when none ran) — and handlers serving 2026-era requests read the per-request identity from `ctx.mcpReq.envelope`. + +### Origin validation middleware and default arming + +The middleware packages now ship Origin header validation alongside the existing Host header validation, and the app factories arm it by default for localhost-class binds: + +```typescript +import { originValidation, localhostOriginValidation } from '@modelcontextprotocol/express'; // also @modelcontextprotocol/hono, /fastify + +const app = createMcpExpressApp(); // localhost bind: Host AND Origin validation armed by default +const appCustom = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'], allowedOrigins: ['myapp.local'] }); +``` + +Requests without an `Origin` header pass unchanged (MCP clients outside a browser do not send one), so non-browser traffic is unaffected. A present `Origin` whose hostname is not allowed — or that cannot be parsed, including the opaque `null` origin — is rejected with +`403` (deny on failure). For a localhost-bound factory app there is no switch that turns Origin validation off: passing an explicit `allowedOrigins` list replaces the default localhost allowlist (use it to allow additional origins, such as a deployed web frontend), and +validation stays armed. The framework-agnostic helpers (`validateOriginHeader`, `localhostAllowedOrigins`, `originValidationResponse`) live in +`@modelcontextprotocol/server` for bare web-standard mounts, and `@modelcontextprotocol/node` now ships request guards (`hostHeaderValidation`, `originValidation` and their `localhost*` variants) for plain `node:http` servers, which previously had no validation helpers. + ### Automatic JSON Schema validator selection by runtime The SDK now automatically selects the appropriate JSON Schema validator based on your runtime environment: diff --git a/docs/server.md b/docs/server.md index d03a6735a5..1b38ac5c83 100644 --- a/docs/server.md +++ b/docs/server.md @@ -62,6 +62,20 @@ const transport = new StdioServerTransport(); await server.connect(transport); ``` +#### Serving the 2026-07-28 draft revision on stdio + +By default a stdio server speaks the 2025-era protocol it was written for (`eraSupport: 'legacy'`): nothing about its wire behavior changes when you upgrade the SDK. Serving the 2026-07-28 draft revision is one explicit option — the transport stays unchanged: + +```typescript +const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { eraSupport: 'dual-era' }); +await server.connect(new StdioServerTransport()); +``` + +With `eraSupport: 'dual-era'` the same long-lived connection serves both eras, selected per message: plain 2025 clients keep using `initialize` and are served exactly as before, while 2026-capable clients negotiate via `server/discover` and send each request with the +per-request `_meta` envelope. Methods that exist in only one era stay invisible to the other (a 2025-era client asking for a 2026-only method gets a plain `-32601`). `eraSupport: 'modern'` is strict 2026-only. On dual-era instances, read per-request client identity from +`ctx.mcpReq.envelope` in your handlers rather than the connection-scoped accessors (see the [migration guide](./migration.md) for details). A runnable example lives at `examples/server/src/dualEraStdio.ts`, with a two-legged client at +`examples/client/src/dualEraStdioClient.ts`. + ## Server instructions Instructions describe how to use the server and its features — cross-tool relationships, workflow patterns, and constraints (see [Instructions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#instructions) in the MCP specification). Clients may add them to the system prompt. Instructions should not duplicate information already in tool descriptions. @@ -598,7 +612,11 @@ const app = createMcpExpressApp({ `createMcpHonoApp()` from `@modelcontextprotocol/hono` provides the same protection for Hono-based servers and Web Standard runtimes (Cloudflare Workers, Deno, Bun). -If you use `NodeStreamableHTTPServerTransport` directly with your own HTTP framework, you must implement Host header validation yourself. See the [`hostHeaderValidation`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/middleware/express/src/express.ts) middleware source for reference. +The app factories also validate the `Origin` header with the same arming rules: localhost-class binds are protected by default, and an explicit `allowedOrigins` list (hostnames, port-agnostic — the same convention as `allowedHosts`) replaces the default localhost allowlist; there is no option that disables Origin validation for a localhost-class bind. Requests without +an `Origin` header always pass, so MCP clients outside a browser are unaffected; a present `Origin` that is not allowed, or that cannot be parsed, is rejected with `403`. The per-framework middleware (`originValidation`, `localhostOriginValidation`) can also be mounted +explicitly, and `@modelcontextprotocol/node` ships equivalent request guards for plain `node:http` servers. + +If you use `NodeStreamableHTTPServerTransport` directly with your own HTTP framework, you must implement Host header validation yourself. See the [`hostHeaderValidation`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/middleware/express/src/express.ts) middleware source for reference. When mounting a handler bare on a fetch-native runtime, the framework-agnostic helpers from `@modelcontextprotocol/server` (`hostHeaderValidationResponse`, `originValidationResponse`) cover the same checks before the request reaches the handler. ## See also diff --git a/examples/client/src/dualEraStdioClient.ts b/examples/client/src/dualEraStdioClient.ts new file mode 100644 index 0000000000..e58bdcdedd --- /dev/null +++ b/examples/client/src/dualEraStdioClient.ts @@ -0,0 +1,68 @@ +/** + * Drives the dual-era stdio server example (`examples/server/src/dualEraStdio.ts`) + * with both kinds of client over a real child-process pipe: + * + * 1. a plain 2025 client — the `initialize` handshake, served exactly as today; + * 2. a 2026-capable client (`versionNegotiation: { mode: 'auto' }`) — the + * `server/discover` probe negotiates the 2026-07-28 revision on the pipe + * (no `initialize` is ever sent), and each modern request carries the + * per-request `_meta` envelope. (Attaching the envelope explicitly is a + * stop-gap: automatic per-request envelope emission is a client-side + * follow-up.) + * + * The client spawns the server example directly from source over stdio: + * + * tsx examples/client/src/dualEraStdioClient.ts + */ +import path from 'node:path'; + +import { Client, CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +// Spawn the sibling server example straight from its source (no build step), +// located relative to this file so the demo runs from any working directory. +const SERVER_SOURCE = path.resolve(import.meta.dirname, '../../server/src/dualEraStdio.ts'); +const SERVER = { command: 'npx', args: ['tsx', SERVER_SOURCE] }; + +async function legacyLeg(): Promise { + console.log('--- leg 1: plain 2025 client (initialize handshake) ---'); + const client = new Client({ name: 'legacy-demo-client', version: '1.0.0' }); + await client.connect(new StdioClientTransport(SERVER)); + + console.log('negotiated protocol version:', client.getNegotiatedProtocolVersion()); + const tools = await client.listTools(); + console.log( + 'tools:', + tools.tools.map(tool => tool.name) + ); + const result = await client.callTool({ name: 'greet', arguments: { name: '2025 client' } }); + console.log('greet result:', JSON.stringify(result.content)); + await client.close(); +} + +async function modernLeg(): Promise { + console.log('--- leg 2: 2026-capable client (server/discover negotiation) ---'); + const client = new Client({ name: 'modern-demo-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StdioClientTransport(SERVER)); + + const negotiated = client.getNegotiatedProtocolVersion(); + console.log('negotiated protocol version:', negotiated); + + // The per-request envelope every 2026-era request carries on the wire. + const envelope = { + [PROTOCOL_VERSION_META_KEY]: negotiated, + [CLIENT_INFO_META_KEY]: { name: 'modern-demo-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; + + const result = await client.request({ + method: 'tools/call', + params: { name: 'greet', arguments: { name: '2026 client' }, _meta: envelope } + }); + console.log('greet result:', JSON.stringify(result.content)); + await client.close(); +} + +await legacyLeg(); +await modernLeg(); +console.log('both legs served by the same dual-era stdio server.'); diff --git a/examples/server/src/dualEraStdio.ts b/examples/server/src/dualEraStdio.ts new file mode 100644 index 0000000000..28009e0bc9 --- /dev/null +++ b/examples/server/src/dualEraStdio.ts @@ -0,0 +1,68 @@ +/** + * Dual-era stdio serving with `eraSupport: 'dual-era'`: one server process, + * one long-lived pipe, both protocol eras. + * + * The same construction backs both legs — nothing about the transport or the + * tool changes per era: + * + * - a plain 2025 client connects with the `initialize` handshake and is served + * exactly as today; + * - a 2026-capable client (`versionNegotiation: { mode: 'auto' }`) negotiates + * the 2026-07-28 revision via `server/discover` on the same pipe and is + * served on the modern era, message by message. + * + * Opting in is the single `eraSupport` option; the default (`'legacy'`) + * preserves today's behavior exactly. + * + * Run with `tsx examples/server/src/dualEraStdio.ts` (or point any stdio MCP + * client at it). `examples/client/src/dualEraStdioClient.ts` drives both legs + * against the built version of this file. + */ +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +// One construction for both legs: tools are defined once and served +// identically to 2025-era and 2026-era clients. +const buildServer = () => { + const server = new McpServer( + { + name: 'dual-era-stdio-server', + version: '1.0.0' + }, + { + capabilities: { tools: {} }, + instructions: 'A small dual-era stdio demo server.', + // The one declared act: serve both protocol eras on this long-lived pipe. + eraSupport: 'dual-era' + } + ); + + server.registerTool( + 'greet', + { + description: 'Greets the caller', + inputSchema: z.object({ name: z.string().describe('Name to greet') }) + }, + async ({ name }): Promise => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) + ); + + return server; +}; + +const server = buildServer(); +// The transport is unchanged: dual-era support is purely a server-options declaration. +await server.connect(new StdioServerTransport()); +console.error('dual-era stdio server ready (serving 2025-era initialize and 2026-07-28 envelope traffic)'); + +const exit = async () => { + await server.close(); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); +}; + +process.on('SIGINT', exit); +process.on('SIGTERM', exit); diff --git a/examples/server/src/dualEraStreamableHttp.ts b/examples/server/src/dualEraStreamableHttp.ts new file mode 100644 index 0000000000..b10121ebad --- /dev/null +++ b/examples/server/src/dualEraStreamableHttp.ts @@ -0,0 +1,96 @@ +/** + * Dual-era HTTP serving with `createMcpHandler`: one factory, one endpoint, + * both protocol eras. + * + * The same factory backs every serving mode; the `MCP_LEGACY_MODE` environment + * variable selects how 2025-era (non-envelope) traffic is handled: + * + * - `MCP_LEGACY_MODE=none` → modern-only strict: 2026-07-28 requests are + * served, 2025-era requests get the documented + * rejection naming the supported revisions. + * - `MCP_LEGACY_MODE=stateless` → (default) 2025-era traffic is additionally + * served per-request via the stateless idiom. + * - `MCP_LEGACY_MODE=byo` → the same, but wired explicitly through the + * exported `legacyStatelessFallback` slot value + * (stand-in for bringing your own legacy handler, + * e.g. an existing sessionful wiring). + * + * Run with `tsx examples/server/src/dualEraStreamableHttp.ts`, then point any + * plain 2025 client at http://localhost:3000/mcp (served through the legacy + * slot when one is configured). A `versionNegotiation: { mode: 'auto' }` + * client negotiates 2026-07-28 against the same endpoint, but automatic + * envelope emission for every request is still a client-side follow-up: + * ordinary typed calls (for example `callTool`) must attach the per-request + * `_meta` envelope explicitly for now (see + * `test/integration/test/server/createMcpHandler.test.ts` for the pattern), + * or the endpoint rejects them on the header/body cross-check. + */ +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import type { CallToolResult, CreateMcpHandlerOptions, McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, legacyStatelessFallback, McpServer } from '@modelcontextprotocol/server'; +import type { Request, Response } from 'express'; +import * as z from 'zod/v4'; + +// One factory for both legs (and every slot state): tools are defined once and +// served identically to 2025-era and 2026-era clients. +const getServer = (ctx: McpRequestContext) => { + const server = new McpServer( + { + name: 'dual-era-server', + version: '1.0.0' + }, + { capabilities: { tools: {} }, instructions: 'A small dual-era demo server.' } + ); + + server.registerTool( + 'greet', + { + description: 'Greets the caller and reports which protocol era served the request', + inputSchema: z.object({ name: z.string().describe('Name to greet') }) + }, + async ({ name }): Promise => ({ + content: [{ type: 'text', text: `Hello, ${name}! (served on the ${ctx.era} protocol era)` }] + }) + ); + + return server; +}; + +const legacyMode = process.env.MCP_LEGACY_MODE ?? 'stateless'; +const options: CreateMcpHandlerOptions = { + onerror: error => console.error('MCP handler error:', error.message) +}; +if (legacyMode === 'stateless') { + options.legacy = 'stateless'; +} else if (legacyMode === 'byo') { + // Bring-your-own legacy serving: any fetch-shaped handler works here. The + // canonical stateless fallback doubles as the simplest BYO value; an + // existing sessionful streamable HTTP wiring would be passed the same way. + options.legacy = legacyStatelessFallback(getServer); +} + +const handler = createMcpHandler(getServer, options); + +// Origin/Host validation is middleware, not entry, concern: the Express app +// factory arms both for localhost binds by default. +const app = createMcpExpressApp(); + +app.all('/mcp', (req: Request, res: Response) => { + void handler.node(req, res, req.body); +}); + +const PORT = 3000; +app.listen(PORT, error => { + if (error) { + console.error('Failed to start server:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + console.log(`Dual-era MCP server listening on http://localhost:${PORT}/mcp (legacy mode: ${legacyMode})`); +}); + +process.on('SIGINT', async () => { + await handler.close(); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); +}); diff --git a/package.json b/package.json index d1ecc0c627..03c4132988 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "mcp" ], "scripts": { + "fetch:schema-twins": "tsx scripts/fetch-schema-twins.ts", + "fetch:spec-examples": "tsx scripts/fetch-spec-examples.ts", "fetch:spec-types": "tsx scripts/fetch-spec-types.ts", "sync:snippets": "tsx scripts/sync-snippets.ts", "examples:simple-server:w": "pnpm --filter @modelcontextprotocol/examples-server exec tsx --watch src/simpleStreamableHttp.ts --oauth", @@ -46,14 +48,13 @@ "test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all" }, "devDependencies": { - "lefthook": "^2.0.16", "@cfworker/json-schema": "catalog:runtimeShared", "@changesets/changelog-github": "^0.5.2", "@changesets/cli": "^2.29.8", "@eslint/js": "catalog:devTools", "@modelcontextprotocol/client": "workspace:^", - "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/node": "workspace:^", + "@modelcontextprotocol/server": "workspace:^", "@types/content-type": "catalog:devTools", "@types/cors": "catalog:devTools", "@types/cross-spawn": "catalog:devTools", @@ -67,6 +68,7 @@ "eslint-config-prettier": "catalog:devTools", "eslint-plugin-n": "catalog:devTools", "fast-glob": "^3.3.3", + "lefthook": "^2.0.16", "prettier": "catalog:devTools", "supertest": "catalog:devTools", "tsdown": "catalog:devTools", diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index bc3a91150b..9ae2950719 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -2,13 +2,19 @@ import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/client/_shims' import type { BaseContext, CallToolRequest, + CallToolResult, ClientCapabilities, ClientContext, ClientNotification, ClientRequest, CompleteRequest, + CompleteResult, + DiscoverResult, + EmptyResult, GetPromptRequest, + GetPromptResult, Implementation, + JSONRPCNotification, JSONRPCRequest, JsonSchemaType, JsonSchemaValidator, @@ -16,14 +22,19 @@ import type { ListChangedHandlers, ListChangedOptions, ListPromptsRequest, + ListPromptsResult, ListResourcesRequest, + ListResourcesResult, ListResourceTemplatesRequest, + ListResourceTemplatesResult, ListToolsRequest, + ListToolsResult, LoggingLevel, MessageExtraInfo, NotificationMethod, ProtocolOptions, ReadResourceRequest, + ReadResourceResult, RequestMethod, RequestOptions, Result, @@ -34,32 +45,27 @@ import type { UnsubscribeRequest } from '@modelcontextprotocol/core'; import { - CallToolResultSchema, - CompleteResultSchema, - CreateMessageRequestSchema, + codecForVersion, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - ElicitRequestSchema, - ElicitResultSchema, - EmptyResultSchema, - GetPromptResultSchema, - InitializeResultSchema, - LATEST_PROTOCOL_VERSION, + DEFAULT_REQUEST_TIMEOUT_MSEC, + DiscoverResultSchema, + isJSONRPCRequest, + isModernProtocolVersion, + legacyProtocolVersions, ListChangedOptionsBaseSchema, - ListPromptsResultSchema, - ListResourcesResultSchema, - ListResourceTemplatesResultSchema, - ListToolsResultSchema, mergeCapabilities, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, - ReadResourceResultSchema, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import type { ResolvedVersionNegotiation, VersionNegotiationOptions } from './versionNegotiation.js'; +import { detectProbeEnvironment, detectProbeTransportKind, negotiateEra, resolveVersionNegotiation } from './versionNegotiation.js'; + /** * Elicitation default application helper. Applies defaults to the `data` based on the `schema`. * @@ -150,6 +156,28 @@ export type ClientOptions = ProtocolOptions & { */ jsonSchemaValidator?: jsonSchemaValidator; + /** + * Opt-in protocol version negotiation (protocol revision 2026-07-28 and later). + * + * - absent or `mode: 'legacy'` — the plain 2025 connect sequence, byte-identical + * to today's behavior (no probe, no new headers). + * - `mode: 'auto'` — `connect()` probes the server with `server/discover` first: + * definitive modern evidence selects the modern era; definitive legacy signals + * (and anything unrecognized) fall back to the plain legacy `initialize` + * handshake on the same connection, byte-equivalent to a 2025 client. A + * network outage rejects with a typed connect error. A probe timeout is + * transport-aware: on stdio it indicates a legacy server (some legacy servers + * never answer unknown pre-`initialize` requests) and falls back to + * `initialize` on the same stream; on HTTP it rejects with a typed timeout + * error (silence on a deployed server is an outage, not a legacy signal). + * - `mode: { pin: '2026-07-28' }` — modern era at exactly the pinned revision; + * no probe-and-fallback: anything else fails loudly. + * + * Probe policy lives under `probe: { timeoutMs? }`; the probe inherits the + * client's standard request timeout unless overridden. + */ + versionNegotiation?: VersionNegotiationOptions; + /** * Configure handlers for list changed notifications (tools, prompts, resources). * @@ -216,7 +244,6 @@ export type ClientOptions = ProtocolOptions & { export class Client extends Protocol { private _serverCapabilities?: ServerCapabilities; private _serverVersion?: Implementation; - private _negotiatedProtocolVersion?: string; private _capabilities: ClientCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; @@ -224,6 +251,8 @@ export class Client extends Protocol { private _listChangedDebounceTimers: Map> = new Map(); private _pendingListChangedConfig?: ListChangedHandlers; private _enforceStrictCapabilities: boolean; + private _versionNegotiation?: VersionNegotiationOptions; + private _supportedProtocolVersionsOption?: string[]; /** * Initializes this client with the given name and version information. @@ -236,6 +265,8 @@ export class Client extends Protocol { this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; + this._versionNegotiation = options?.versionNegotiation; + this._supportedProtocolVersionsOption = options?.supportedProtocolVersions; // Store list changed config for setup after connection (when we know server capabilities) if (options?.listChanged) { @@ -247,6 +278,27 @@ export class Client extends Protocol { return ctx; } + /** + * Era-keyed direction enforcement for inbound traffic on channels whose + * transport does not classify (e.g. stdio): the 2026-07-28 era has no + * server→client JSON-RPC request channel — server-to-client interactions + * are carried in-band in `input_required` results — and on stdio the + * client must never write JSON-RPC responses. An inbound request arriving + * on a connection that negotiated a modern era is therefore dropped + * (surfaced via `onerror`) rather than answered. Connections on a legacy + * era — and all responses and notifications — keep today's dispatch path. + */ + protected override _classifyInbound(message: JSONRPCRequest | JSONRPCNotification): 'drop' | undefined { + if ( + this._negotiatedProtocolVersion !== undefined && + isModernProtocolVersion(this._negotiatedProtocolVersion) && + isJSONRPCRequest(message) + ) { + return 'drop'; + } + return undefined; + } + /** * Set up handlers for list changed notifications based on config and server capabilities. * This should only be called after initialization when server capabilities are known. @@ -299,7 +351,19 @@ export class Client extends Protocol { ): (request: JSONRPCRequest, ctx: ClientContext) => Promise { if (method === 'elicitation/create') { return async (request, ctx) => { - const validatedRequest = parseSchema(ElicitRequestSchema, request); + // Era-exact validation: the schemas are resolved from the + // instance era at dispatch time (the era gate guarantees the + // method exists on the serving era before we get here). + const codec = codecForVersion(this._negotiatedProtocolVersion); + const elicitRequestSchema = codec.requestSchema('elicitation/create'); + // The era registry entry IS the plain ElicitResult schema + // (the result map is aligned to the typed map — no widened + // unions), so no narrower surface is needed. + const elicitResultSchema = codec.resultSchema('elicitation/create'); + if (!elicitRequestSchema || !elicitResultSchema) { + throw new ProtocolError(ProtocolErrorCode.InternalError, 'No wire schema for elicitation/create in the resolved era'); + } + const validatedRequest = parseSchema(elicitRequestSchema, request); if (!validatedRequest.success) { // Type guard: if success is false, error is guaranteed to exist const errorMessage = @@ -321,7 +385,7 @@ export class Client extends Protocol { const result = await handler(request, ctx); - const validationResult = parseSchema(ElicitResultSchema, result); + const validationResult = parseSchema(elicitResultSchema, result); if (!validationResult.success) { // Type guard: if success is false, error is guaranteed to exist const errorMessage = @@ -352,7 +416,16 @@ export class Client extends Protocol { if (method === 'sampling/createMessage') { return async (request, ctx) => { - const validatedRequest = parseSchema(CreateMessageRequestSchema, request); + // Era-exact validation via the instance era (see above). + const codec = codecForVersion(this._negotiatedProtocolVersion); + const samplingRequestSchema = codec.requestSchema('sampling/createMessage'); + if (!samplingRequestSchema) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + 'No wire schema for sampling/createMessage in the resolved era' + ); + } + const validatedRequest = parseSchema(samplingRequestSchema, request); if (!validatedRequest.success) { const errorMessage = validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); @@ -363,6 +436,11 @@ export class Client extends Protocol { const result = await handler(request, ctx); + // The result schema depends on the REQUEST params (tools vs + // no tools) — something a method-keyed registry entry cannot + // express, so the pair is picked here. The era gate keeps + // this era-correct: sampling/createMessage is only ever + // dispatched on an era whose registry defines it. const hasTools = params.tools || params.toolChoice; const resultSchema = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema; const validationResult = parseSchema(resultSchema, result); @@ -415,27 +493,65 @@ export class Client extends Protocol { * ``` */ override async connect(transport: Transport, options?: RequestOptions): Promise { + const negotiation = resolveVersionNegotiation(this._versionNegotiation, this._supportedProtocolVersionsOption); + if (negotiation.kind !== 'legacy') { + return this._connectNegotiated(transport, negotiation, options); + } + // Plain legacy connect — the pinned 2025 sequence, byte-untouched. await super.connect(transport); // When transport sessionId is already set this means we are trying to reconnect. // Restore the protocol version negotiated during the original initialize handshake // so HTTP transports include the required mcp-protocol-version header, but skip re-init. if (transport.sessionId !== undefined) { - if (this._negotiatedProtocolVersion !== undefined && transport.setProtocolVersion) { - transport.setProtocolVersion(this._negotiatedProtocolVersion); + const negotiatedProtocolVersion = this._negotiatedProtocolVersion; + if (negotiatedProtocolVersion !== undefined) { + // Resuming keeps the original negotiation: the instance still + // holds the negotiated version (and with it the wire era) — + // only the new transport needs the header pushed again. + transport.setProtocolVersion?.(negotiatedProtocolVersion); } return; } + // Fresh connect: the negotiated protocol version is connection state — + // a value left over from a previous connection must not survive into a + // new handshake. Clearing it puts the instance back in the + // pre-negotiation phase, so the initialize exchange below rides the + // bootstrap method pins (legacy era) instead of a dead session's era. + // Without this, an instance that once negotiated a modern era could + // never re-run a fresh handshake: `initialize` is physically absent + // from the modern registry. (The resume branch above keeps it instead.) + this._negotiatedProtocolVersion = undefined; + await this._legacyHandshake(transport, options); + } + + /** + * The 2025 `initialize` handshake — the body of the plain legacy connect and + * the `'auto'`-mode fallback path (same connection, same `initialize` body, + * zero 2026 headers). Callers clear the negotiated protocol version before + * the handshake; its completion sets the negotiated (legacy) version. + */ + private async _legacyHandshake(transport: Transport, options?: RequestOptions): Promise { + // initialize is a legacy-era handshake: only the legacy subset of the + // supported versions is ever offered or accepted here — a 2026-era + // revision is negotiated exclusively via server/discover. + const legacyVersions = legacyProtocolVersions(this._supportedProtocolVersions); try { - const result = await this._requestWithSchema( + const offeredVersion = legacyVersions[0]; + if (offeredVersion === undefined) { + throw new SdkError( + SdkErrorCode.EraNegotiationFailed, + 'Cannot run the initialize handshake: supportedProtocolVersions contains no pre-2026-07-28 protocol version' + ); + } + const result = await this.request( { method: 'initialize', params: { - protocolVersion: this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION, + protocolVersion: offeredVersion, capabilities: this._capabilities, clientInfo: this._clientInfo } }, - InitializeResultSchema, options ); @@ -443,13 +559,12 @@ export class Client extends Protocol { throw new Error(`Server sent invalid initialize result: ${result}`); } - if (!this._supportedProtocolVersions.includes(result.protocolVersion)) { + if (!legacyVersions.includes(result.protocolVersion)) { throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); } this._serverCapabilities = result.capabilities; this._serverVersion = result.serverInfo; - this._negotiatedProtocolVersion = result.protocolVersion; // HTTP transports must set the protocol version in each header after initialization. if (transport.setProtocolVersion) { transport.setProtocolVersion(result.protocolVersion); @@ -461,6 +576,15 @@ export class Client extends Protocol { method: 'notifications/initialized' }); + // Handshake completion: the negotiated version becomes the + // instance's connection state, and with it the wire era for + // everything this connection sends/receives from here on (the + // negotiated version cashes out as the negotiated wire ERA — + // Q1-SD1). Set AFTER the initialized notification: the initialize + // EXCHANGE is the legacy handshake by definition and completes on + // that era. + this._negotiatedProtocolVersion = result.protocolVersion; + // Set up list changed handlers now that we know server capabilities if (this._pendingListChangedConfig) { this._setupListChangedHandlers(this._pendingListChangedConfig); @@ -473,6 +597,73 @@ export class Client extends Protocol { } } + /** + * Negotiated connect (mode `'auto'` or `{ pin }`): probe with `server/discover` + * before the Protocol machinery attaches, then either establish the modern era + * or perform the plain legacy handshake on the same connection. + */ + private async _connectNegotiated( + transport: Transport, + negotiation: Extract, + options?: RequestOptions + ): Promise { + // Session-resuming reconnect: restore the previously negotiated version, + // never re-probe mid-session. + if (transport.sessionId !== undefined) { + await super.connect(transport); + const negotiatedProtocolVersion = this._negotiatedProtocolVersion; + if (negotiatedProtocolVersion !== undefined && transport.setProtocolVersion) { + transport.setProtocolVersion(negotiatedProtocolVersion); + } + return; + } + + // Fresh connect: stale connection state must not survive into a new + // negotiation — every fresh negotiated connect re-runs the probe. + this._negotiatedProtocolVersion = undefined; + + let result: Awaited>; + try { + result = await negotiateEra(negotiation, { + transport, + clientInfo: this._clientInfo, + capabilities: this._capabilities, + environment: detectProbeEnvironment(), + transportKind: detectProbeTransportKind(transport), + defaultTimeoutMs: options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC + }); + } catch (error) { + // Typed connect error — close the channel like a failed initialize does. + await transport.close().catch(() => {}); + throw error; + } + + await super.connect(transport); + + if (result.era === 'legacy') { + // Conservative fallback: the plain legacy handshake on the SAME + // connection (the probe never touched the transport version slot). + await this._legacyHandshake(transport, options); + return; + } + + this._serverCapabilities = result.discover.capabilities; + this._serverVersion = result.discover.serverInfo; + this._instructions = result.discover.instructions; + // Modern selection: the same connection state the legacy handshake completion sets. + this._negotiatedProtocolVersion = result.version; + // The single setProtocolVersion call site on this path, mirroring the legacy path after initialize. + if (transport.setProtocolVersion) { + transport.setProtocolVersion(result.version); + } + // The modern era has no notifications/initialized; list-changed handlers + // are configured straight from the advertised capabilities. + if (this._pendingListChangedConfig) { + this._setupListChangedHandlers(this._pendingListChangedConfig); + this._pendingListChangedConfig = undefined; + } + } + /** * After initialization has completed, this will be populated with the server's reported capabilities. */ @@ -561,6 +752,12 @@ export class Client extends Protocol { break; } + case 'server/discover': { + // No specific capability required for discover (protocol revision + // 2026-07-28; servers on that revision MUST implement it) + break; + } + case 'ping': { // No specific capability required for ping break; @@ -642,13 +839,28 @@ export class Client extends Protocol { } } - async ping(options?: RequestOptions) { - return this._requestWithSchema({ method: 'ping' }, EmptyResultSchema, options); + async ping(options?: RequestOptions): Promise { + return this.request({ method: 'ping' }, options); + } + + /** + * Asks the server to advertise its supported protocol versions, capabilities, + * and implementation info (`server/discover`, protocol revision 2026-07-28). + * + * Servers on the 2026-07-28 revision MUST implement this; the connect-time + * version negotiation issues it automatically. The method exists only on + * the 2026-07-28 era: on a connection negotiated to a 2025-era version it + * is rejected locally with a typed `SdkError` + * (`MethodNotSupportedByProtocolVersion`) before anything reaches the + * transport. + */ + async discover(options?: RequestOptions): Promise { + return this._requestWithSchema({ method: 'server/discover' }, DiscoverResultSchema, options); } /** Requests argument autocompletion suggestions from the server for a prompt or resource. */ - async complete(params: CompleteRequest['params'], options?: RequestOptions) { - return this._requestWithSchema({ method: 'completion/complete', params }, CompleteResultSchema, options); + async complete(params: CompleteRequest['params'], options?: RequestOptions): Promise { + return this.request({ method: 'completion/complete', params }, options); } /** @@ -658,13 +870,13 @@ export class Client extends Protocol { * Remains functional during the deprecation window (at least twelve months). * Migrate to stderr logging (STDIO servers) or OpenTelemetry. */ - async setLoggingLevel(level: LoggingLevel, options?: RequestOptions) { - return this._requestWithSchema({ method: 'logging/setLevel', params: { level } }, EmptyResultSchema, options); + async setLoggingLevel(level: LoggingLevel, options?: RequestOptions): Promise { + return this.request({ method: 'logging/setLevel', params: { level } }, options); } /** Retrieves a prompt by name from the server, passing the given arguments for template substitution. */ - async getPrompt(params: GetPromptRequest['params'], options?: RequestOptions) { - return this._requestWithSchema({ method: 'prompts/get', params }, GetPromptResultSchema, options); + async getPrompt(params: GetPromptRequest['params'], options?: RequestOptions): Promise { + return this.request({ method: 'prompts/get', params }, options); } /** @@ -689,13 +901,13 @@ export class Client extends Protocol { * ); * ``` */ - async listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions) { + async listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions): Promise { if (!this._serverCapabilities?.prompts && !this._enforceStrictCapabilities) { // Respect capability negotiation: server does not support prompts console.debug('Client.listPrompts() called but server does not advertise prompts capability - returning empty list'); return { prompts: [] }; } - return this._requestWithSchema({ method: 'prompts/list', params }, ListPromptsResultSchema, options); + return this.request({ method: 'prompts/list', params }, options); } /** @@ -720,13 +932,13 @@ export class Client extends Protocol { * ); * ``` */ - async listResources(params?: ListResourcesRequest['params'], options?: RequestOptions) { + async listResources(params?: ListResourcesRequest['params'], options?: RequestOptions): Promise { if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) { // Respect capability negotiation: server does not support resources console.debug('Client.listResources() called but server does not advertise resources capability - returning empty list'); return { resources: [] }; } - return this._requestWithSchema({ method: 'resources/list', params }, ListResourcesResultSchema, options); + return this.request({ method: 'resources/list', params }, options); } /** @@ -735,7 +947,10 @@ export class Client extends Protocol { * Returns an empty list if the server does not advertise resources capability * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). */ - async listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions) { + async listResourceTemplates( + params?: ListResourceTemplatesRequest['params'], + options?: RequestOptions + ): Promise { if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) { // Respect capability negotiation: server does not support resources console.debug( @@ -743,22 +958,22 @@ export class Client extends Protocol { ); return { resourceTemplates: [] }; } - return this._requestWithSchema({ method: 'resources/templates/list', params }, ListResourceTemplatesResultSchema, options); + return this.request({ method: 'resources/templates/list', params }, options); } /** Reads the contents of a resource by URI. */ - async readResource(params: ReadResourceRequest['params'], options?: RequestOptions) { - return this._requestWithSchema({ method: 'resources/read', params }, ReadResourceResultSchema, options); + async readResource(params: ReadResourceRequest['params'], options?: RequestOptions): Promise { + return this.request({ method: 'resources/read', params }, options); } /** Subscribes to change notifications for a resource. The server must support resource subscriptions. */ - async subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions) { - return this._requestWithSchema({ method: 'resources/subscribe', params }, EmptyResultSchema, options); + async subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions): Promise { + return this.request({ method: 'resources/subscribe', params }, options); } /** Unsubscribes from change notifications for a resource. */ - async unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions) { - return this._requestWithSchema({ method: 'resources/unsubscribe', params }, EmptyResultSchema, options); + async unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions): Promise { + return this.request({ method: 'resources/unsubscribe', params }, options); } /** @@ -798,8 +1013,12 @@ export class Client extends Protocol { * } * ``` */ - async callTool(params: CallToolRequest['params'], options?: RequestOptions) { - const result = await this._requestWithSchema({ method: 'tools/call', params }, CallToolResultSchema, options); + async callTool(params: CallToolRequest['params'], options?: RequestOptions): Promise { + // The method-keyed request() path validates the era registry's plain + // CallToolResult schema — with the result map aligned to the typed + // map there is no wider union to narrow away (Q1-SD2 holds by + // construction). + const result = await this.request({ method: 'tools/call', params }, options); // Check if the tool has an outputSchema const validator = this.getToolOutputValidator(params.name); @@ -884,13 +1103,13 @@ export class Client extends Protocol { * ); * ``` */ - async listTools(params?: ListToolsRequest['params'], options?: RequestOptions) { + async listTools(params?: ListToolsRequest['params'], options?: RequestOptions): Promise { if (!this._serverCapabilities?.tools && !this._enforceStrictCapabilities) { // Respect capability negotiation: server does not support tools console.debug('Client.listTools() called but server does not advertise tools capability - returning empty list'); return { tools: [] }; } - const result = await this._requestWithSchema({ method: 'tools/list', params }, ListToolsResultSchema, options); + const result = await this.request({ method: 'tools/list', params }, options); // Cache the tools and their output schemas for future validation this.cacheToolMetadata(result.tools); diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts new file mode 100644 index 0000000000..25dd025a3b --- /dev/null +++ b/packages/client/src/client/probeClassifier.ts @@ -0,0 +1,254 @@ +/** + * Probe outcome classifier (pure module): maps the outcome of the connect-time + * `server/discover` probe onto one of four verdicts — modern era, the + * spec-mandated `-32004` corrective continuation, legacy fallback (the plain + * 2025 `initialize` handshake on the same connection), or a typed connect error. + * + * The classifier is deliberately conservative: anything it does not positively + * recognize as modern resolves to the legacy fallback, and a network outage is a + * typed connect error, never an era verdict. The verdicts apply to the + * negotiation phase only — an established modern connection is never silently + * demoted to `initialize` by a later failure. + */ +import type { DiscoverResult } from '@modelcontextprotocol/core'; +import { + DiscoverResultSchema, + modernProtocolVersions, + SdkError, + SdkErrorCode, + UnsupportedProtocolVersionError +} from '@modelcontextprotocol/core'; + +/** + * The runtime environment the probe executed in. Only consulted for the + * network-failure row: a browser CORS-preflight rejection is treated as a + * legacy signal, while in Node a network failure stays a typed connect error. + */ +export type ProbeEnvironment = 'node' | 'browser'; + +/** + * The transport class the probe ran on. Only consulted for the timeout row: a + * stdio probe that times out signals a legacy server, while an HTTP timeout + * stays a typed error. Anything that is not the stdio child-process transport + * is treated like HTTP. + */ +export type ProbeTransportKind = 'stdio' | 'http'; + +/** + * A normalized probe outcome, produced by the connect-time wiring from the raw + * transport exchange. + */ +export type ProbeOutcome = + | { kind: 'result'; result: unknown } + /** Answered with a JSON-RPC error (any HTTP status, including 200-bodied errors and stdio in-band errors). */ + | { kind: 'rpc-error'; code: number; message: string; data?: unknown } + /** The HTTP layer rejected the probe POST (non-2xx); `body` is the raw response text, when available. */ + | { kind: 'http-error'; status: number; body?: string } + | { kind: 'network-error'; error: unknown } + /** No response arrived within the probe timeout. */ + | { kind: 'timeout'; timeoutMs: number }; + +export interface ProbeClassifierContext { + /** Modern-era versions this client can negotiate, in preference order (never empty). */ + clientModernVersions: readonly string[]; + /** The version the probe carried in its `_meta` envelope (used to synthesize `data.requested` on typed errors). */ + requestedVersion: string; + /** + * Whether a legacy `initialize` fallback is possible — `false` for a + * modern-only client and for `pin` mode. Without a fallback, rows carrying + * modern evidence but no usable version overlap — a `DiscoverResult` with + * no overlapping version, or a `-32004` whose `data.supported` lists only + * legacy revisions — yield a typed `UnsupportedProtocolVersionError` built + * from that evidence; the remaining rows that would have fallen back still + * classify as `legacy`, and the caller reports them as a typed negotiation + * error instead of starting an `initialize` handshake. + */ + fallbackAvailable: boolean; + /** See {@linkcode ProbeEnvironment}. */ + environment: ProbeEnvironment; + /** See {@linkcode ProbeTransportKind}. */ + transportKind: ProbeTransportKind; +} + +export type ProbeVerdict = + /** Definitive modern evidence: select `version` and continue without `initialize`. */ + | { kind: 'modern'; version: string; discover: DiscoverResult } + /** + * `-32004` with a mutual modern version: re-send the probe at `version`. + * Spec-mandated select-and-continue — the caller runs it exactly once and + * arms a loop guard on the second rejection, throwing `error`. + */ + | { kind: 'corrective'; version: string; error: UnsupportedProtocolVersionError } + /** Definitive legacy signal or unrecognized shape: perform the plain legacy `initialize` handshake on the same connection. */ + | { kind: 'legacy' } + /** Typed connect error — never converted to an era verdict. */ + | { kind: 'error'; error: Error }; + +/** The `-32004` UnsupportedProtocolVersion protocol error code (negotiation-phase recognition). */ +const UNSUPPORTED_PROTOCOL_VERSION = -32_004; +/** + * Deliberately not probe-recognized in either direction: deployed servers + * overload `-32001` and the error-code ladder for these cells is still being + * derived upstream, so both fall into the conservative legacy default. + */ +const NOT_PROBE_RECOGNIZED = new Set([-32_001, -32_003]); + +/** + * Classify a single probe outcome. Pure: no I/O, no state — loop-guard and + * retry state live in the caller. + */ +export function classifyProbeOutcome(outcome: ProbeOutcome, context: ProbeClassifierContext): ProbeVerdict { + switch (outcome.kind) { + case 'result': { + return classifyResult(outcome.result, context); + } + case 'rpc-error': { + return classifyRpcError(outcome, context); + } + case 'http-error': { + return classifyHttpError(outcome, context); + } + case 'network-error': { + return classifyNetworkError(outcome.error, context); + } + case 'timeout': { + if (context.transportKind === 'stdio') { + // Per the stdio transport's backward-compatibility rule, a probe + // nobody answers within the timeout indicates a legacy server — + // fall back to `initialize` on the same stream. + return { kind: 'legacy' }; + } + // On HTTP a deployed server answers, so silence is an outage, not a + // legacy signal: keep the typed timeout error (the compatibility + // matrix keys the HTTP legacy signal to a 4xx, never to silence). + return { + kind: 'error', + error: new SdkError(SdkErrorCode.RequestTimeout, `Version negotiation probe timed out after ${outcome.timeoutMs}ms`, { + timeout: outcome.timeoutMs + }) + }; + } + } +} + +function classifyResult(result: unknown, context: ProbeClassifierContext): ProbeVerdict { + const parsed = DiscoverResultSchema.safeParse(result); + if (!parsed.success) { + // Unrecognized result shape: not modern evidence — conservative legacy fallback. + return { kind: 'legacy' }; + } + const supportedVersions = parsed.data.supportedVersions; + const overlap = context.clientModernVersions.find(version => supportedVersions.includes(version)); + if (overlap !== undefined) { + return { kind: 'modern', version: overlap, discover: parsed.data }; + } + // A DiscoverResult with no overlap still drives era selection: initialize on + // the same connection when fallback is possible, otherwise a typed error. + if (context.fallbackAvailable) { + return { kind: 'legacy' }; + } + return { + kind: 'error', + error: new UnsupportedProtocolVersionError({ supported: [...supportedVersions], requested: context.requestedVersion }) + }; +} + +function classifyRpcError(outcome: { code: number; message: string; data?: unknown }, context: ProbeClassifierContext): ProbeVerdict { + const { code, message, data } = outcome; + + if (code === UNSUPPORTED_PROTOCOL_VERSION) { + const supported = parseSupportedList(data); + if (supported === undefined) { + // -32004 without a valid data.supported list is not actionable modern evidence. + return { kind: 'legacy' }; + } + const requested = parseRequested(data) ?? context.requestedVersion; + const error = new UnsupportedProtocolVersionError({ supported, requested }, message); + const supportedModern = modernProtocolVersions(supported); + const mutual = context.clientModernVersions.find(version => supportedModern.includes(version)); + if (mutual !== undefined) { + // Mutual modern version: spec-mandated select-and-continue — never + // fall back to initialize here. + return { kind: 'corrective', version: mutual, error }; + } + if (supportedModern.length > 0) { + // Disjoint-but-modern list: typed error, never initialize. + return { kind: 'error', error }; + } + // Legacy-only list: definitive legacy signal (typed error for a modern-only client). + return context.fallbackAvailable ? { kind: 'legacy' } : { kind: 'error', error }; + } + + if (NOT_PROBE_RECOGNIZED.has(code)) { + return { kind: 'legacy' }; + } + + // Everything else — -32601, the deployed -32000 literals/free-text, code 0, + // any unrecognized code — is a legacy signal or the conservative default. + return { kind: 'legacy' }; +} + +function classifyHttpError(outcome: { status: number; body?: string }, context: ProbeClassifierContext): ProbeVerdict { + // HTTP-rejected probes carry their JSON-RPC error in the response body — classify it like an in-band error. + const rpcError = parseJsonRpcErrorBody(outcome.body); + if (rpcError !== undefined) { + return classifyRpcError(rpcError, context); + } + // Unparseable or unrecognized HTTP rejection: conservative legacy fallback. + return { kind: 'legacy' }; +} + +function classifyNetworkError(error: unknown, context: ProbeClassifierContext): ProbeVerdict { + if (context.environment === 'browser' && isOpaqueFetchTypeError(error)) { + // A browser CORS-preflight rejection against a deployed 2025 server is an + // opaque TypeError; the legacy fallback carries no custom headers (no + // preflight), so it can proceed where the probe could not. + return { kind: 'legacy' }; + } + return { + kind: 'error', + error: new SdkError(SdkErrorCode.EraNegotiationFailed, `Version negotiation probe failed: ${describeError(error)}`, { + cause: error + }) + }; +} + +function isOpaqueFetchTypeError(error: unknown): boolean { + // Cross-realm safe: a bundled or sandboxed fetch may not share this realm's TypeError identity. + return error instanceof TypeError || (error instanceof Error && error.name === 'TypeError'); +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function parseSupportedList(data: unknown): string[] | undefined { + if (typeof data !== 'object' || data === null) return undefined; + const supported = (data as { supported?: unknown }).supported; + if (!Array.isArray(supported) || supported.length === 0 || !supported.every(v => typeof v === 'string')) { + return undefined; + } + return supported as string[]; +} + +function parseRequested(data: unknown): string | undefined { + if (typeof data !== 'object' || data === null) return undefined; + const requested = (data as { requested?: unknown }).requested; + return typeof requested === 'string' ? requested : undefined; +} + +function parseJsonRpcErrorBody(body: string | undefined): { code: number; message: string; data?: unknown } | undefined { + if (body === undefined || body === '') return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return undefined; + } + if (typeof parsed !== 'object' || parsed === null) return undefined; + const error = (parsed as { error?: unknown }).error; + if (typeof error !== 'object' || error === null) return undefined; + const { code, message, data } = error as { code?: unknown; message?: unknown; data?: unknown }; + if (typeof code !== 'number') return undefined; + return { code, message: typeof message === 'string' ? message : '', data }; +} diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 3b8ddafe5a..5dea9a7cc5 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -9,6 +9,7 @@ import { isJSONRPCResultResponse, JSONRPCMessageSchema, normalizeHeaders, + PROTOCOL_VERSION_META_KEY, SdkError, SdkErrorCode, SdkHttpError @@ -231,6 +232,27 @@ export class StreamableHTTPClientTransport implements Transport { }); } + /** + * Body-derived per-request headers: when an outgoing request carries a + * protocol-version claim in its `_meta` envelope (the version negotiation + * probe is the first such sender), `MCP-Protocol-Version` and `Mcp-Method` + * derive from the message itself. The connection-level version slot is + * neither consulted nor mutated; messages without an envelope claim are + * untouched, so no 2026 header can appear on a legacy exchange. + */ + private _applyBodyDerivedHeaders(headers: Headers, message: JSONRPCMessage | JSONRPCMessage[]): void { + if (Array.isArray(message) || !isJSONRPCRequest(message)) { + return; + } + const meta = (message.params as { _meta?: Record } | undefined)?._meta; + const envelopeVersion = meta?.[PROTOCOL_VERSION_META_KEY]; + if (typeof envelopeVersion !== 'string') { + return; + } + headers.set('mcp-protocol-version', envelopeVersion); + headers.set('mcp-method', message.method); + } + private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false): Promise { const { resumptionToken } = options; @@ -541,6 +563,7 @@ export class StreamableHTTPClientTransport implements Transport { } const headers = await this._commonHeaders(); + this._applyBodyDerivedHeaders(headers, message); headers.set('content-type', 'application/json'); const userAccept = headers.get('accept'); const types = [...(userAccept?.split(',').map(s => s.trim().toLowerCase()) ?? []), 'application/json', 'text/event-stream']; diff --git a/packages/client/src/client/versionNegotiation.ts b/packages/client/src/client/versionNegotiation.ts new file mode 100644 index 0000000000..f4b80511ca --- /dev/null +++ b/packages/client/src/client/versionNegotiation.ts @@ -0,0 +1,402 @@ +/** + * Connect-time protocol version negotiation (opt-in via + * `ClientOptions.versionNegotiation`): the option surface, the probe window (a + * raw transport exchange run before the Protocol machinery attaches), and the + * negotiation engine driving the pure {@linkcode classifyProbeOutcome} classifier. + * + * Invariants: the probe uses string ids and consumes no Protocol message ids, so + * a legacy fallback's `initialize` is byte-equivalent to a plain legacy connect; + * the transport's protocol-version slot is never mutated during negotiation + * (probe headers derive from the probe message body) and is set exactly once + * after a modern resolution; while the probe window is open, inbound messages + * that are not the probe response are dropped with zero bytes written back. + */ +import type { ClientCapabilities, DiscoverResult, Implementation, JSONRPCRequest, Transport } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + isJSONRPCErrorResponse, + isJSONRPCResultResponse, + isModernProtocolVersion, + legacyProtocolVersions, + modernProtocolVersions, + PROTOCOL_VERSION_META_KEY, + SdkError, + SdkErrorCode, + SdkHttpError, + SUPPORTED_MODERN_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/core'; + +import type { ProbeEnvironment, ProbeOutcome, ProbeTransportKind, ProbeVerdict } from './probeClassifier.js'; +import { classifyProbeOutcome } from './probeClassifier.js'; + +/** + * Probe policy for `'auto'` and pinned negotiation modes. + * + * There is no special probe timeout opinion: the probe inherits the client's + * STANDARD request timeout unless `timeoutMs` overrides it. + */ +export interface VersionNegotiationProbeOptions { + /** + * Timeout for the probe exchange, in milliseconds. + * + * The timeout verdict is transport-aware: on stdio, a probe that gets no + * response within the timeout indicates a legacy server and falls back to + * the `initialize` handshake on the same stream; on HTTP, where a deployed + * server answers and silence means an outage, `connect()` rejects with the + * standard typed timeout error instead. + * + * @default the standard request timeout (`DEFAULT_REQUEST_TIMEOUT_MSEC`, or the `timeout` passed to `connect()`) + */ + timeoutMs?: number; +} + +/** + * Negotiation mode: + * + * - `'legacy'` — no negotiation: the plain 2025 connect sequence, byte-identical + * to a client without this option. + * - `'auto'` — probe with `server/discover` at connect; conservative fallback to + * the plain legacy `initialize` handshake on the same connection unless the + * outcome is definitive modern evidence. Network outage rejects with a typed + * connect error; a probe timeout falls back to `initialize` on stdio (a silent + * server on a local pipe is a legacy server) and rejects with a typed timeout + * error on HTTP (silence there is an outage). + * - `{ pin: '' }` — modern era at exactly the pinned revision: the + * connect-time `server/discover` must offer it. No fallback — anything else + * fails loudly with a typed error. + */ +export type VersionNegotiationMode = 'legacy' | 'auto' | { pin: string }; + +/** + * Opt-in protocol version negotiation, configured on + * `ClientOptions.versionNegotiation`. + */ +export interface VersionNegotiationOptions { + /** + * @default 'legacy' + */ + mode?: VersionNegotiationMode; + + /** + * Probe timeout/retry policy (only consulted by the probing modes). + */ + probe?: VersionNegotiationProbeOptions; +} + +/** + * The default mode when `versionNegotiation` (or its `mode`) is absent; + * changing the default later is a flip of this single line. + */ +const DEFAULT_VERSION_NEGOTIATION_MODE: VersionNegotiationMode = 'legacy'; + +/** A fully resolved negotiation plan for one `connect()` call. */ +export type ResolvedVersionNegotiation = + | { kind: 'legacy' } + | { + kind: 'auto'; + /** Modern versions this client offers, in preference order (never empty). */ + modernVersions: string[]; + /** Whether this client can fall back to the legacy `initialize` handshake. */ + fallbackAvailable: boolean; + probe: VersionNegotiationProbeOptions; + } + | { kind: 'pin'; version: string; probe: VersionNegotiationProbeOptions }; + +/** + * Resolve the negotiation options into a per-connect plan. The raw (not + * defaulted) `supportedProtocolVersions` option supplies the modern offer list; + * a list without any legacy version makes this a modern-only client (no fallback). + */ +export function resolveVersionNegotiation( + options: VersionNegotiationOptions | undefined, + supportedProtocolVersionsOption: readonly string[] | undefined +): ResolvedVersionNegotiation { + const mode = options?.mode ?? DEFAULT_VERSION_NEGOTIATION_MODE; + if (mode === 'legacy') { + return { kind: 'legacy' }; + } + const probe = options?.probe ?? {}; + if (typeof mode === 'object') { + if (!isModernProtocolVersion(mode.pin)) { + throw new TypeError( + `versionNegotiation: { pin: '${mode.pin}' } is not a modern protocol revision — ` + + `pinning is for 2026-07-28 and later; omit versionNegotiation (or use mode: 'legacy') for 2025-era servers.` + ); + } + return { kind: 'pin', version: mode.pin, probe }; + } + const explicitModern = supportedProtocolVersionsOption ? modernProtocolVersions(supportedProtocolVersionsOption) : []; + const modernVersions = explicitModern.length > 0 ? explicitModern : [...SUPPORTED_MODERN_PROTOCOL_VERSIONS]; + const fallbackAvailable = supportedProtocolVersionsOption ? legacyProtocolVersions(supportedProtocolVersionsOption).length > 0 : true; + return { kind: 'auto', modernVersions, fallbackAvailable, probe }; +} + +/** Detect the probe environment for the network-failure row — see {@linkcode ProbeEnvironment}. */ +export function detectProbeEnvironment(): ProbeEnvironment { + const g = globalThis as { window?: unknown; document?: unknown }; + return g.window !== undefined && g.document !== undefined ? 'browser' : 'node'; +} + +/** + * Detect the transport class for the transport-aware timeout verdict (see + * {@linkcode ProbeTransportKind}). The stdio child-process transport is + * recognized structurally (`stderr`/`pid` accessors, no `instanceof` — safe + * across bundles); everything else is treated like HTTP. + */ +export function detectProbeTransportKind(transport: Transport): ProbeTransportKind { + return 'stderr' in transport && 'pid' in transport ? 'stdio' : 'http'; +} + +/** Raw reply from one probe exchange, before normalization. */ +type RawProbeReply = + | { kind: 'response'; result?: unknown; error?: { code: number; message: string; data?: unknown } } + | { kind: 'send-error'; error: unknown } + | { kind: 'closed' } + | { kind: 'timeout' }; + +/** + * Temporary ownership of a raw transport for the negotiation exchange, before + * the Protocol machinery attaches. `open()` installs the window's handlers and + * starts the transport; `release()` detaches them and arms a one-shot `start()` + * pass-through so the subsequent Protocol connect (which always starts its + * transport) takes over the already-started channel without a double-start error. + */ +class ProbeWindow { + private _pending: { id: string; resolve: (reply: RawProbeReply) => void } | undefined; + private _probeCounter = 0; + + private constructor(private readonly _transport: Transport) {} + + static async open(transport: Transport): Promise { + const window = new ProbeWindow(transport); + transport.onmessage = message => { + const pending = window._pending; + if ( + pending !== undefined && + (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) && + message.id === pending.id + ) { + window._pending = undefined; + if (isJSONRPCResultResponse(message)) { + pending.resolve({ kind: 'response', result: message.result }); + } else { + pending.resolve({ kind: 'response', error: message.error }); + } + return; + } + // Probe-window guard: drop everything else with zero bytes written back (see module doc). + }; + transport.onerror = () => { + // Out-of-band transport errors are not necessarily fatal; the probe + // resolves via a send failure, the close signal, or the timeout. + }; + transport.onclose = () => { + const pending = window._pending; + if (pending !== undefined) { + window._pending = undefined; + pending.resolve({ kind: 'closed' }); + } + }; + await transport.start(); + return window; + } + + /** + * Send one probe request and await its reply. Probe ids are strings, so they + * never collide with Protocol's numeric ids (e.g. on a shared stdio pipe). + */ + async exchange(buildRequest: (id: string) => JSONRPCRequest, timeoutMs: number): Promise { + const id = `server-discover-probe-${++this._probeCounter}`; + return new Promise(resolve => { + let settled = false; + const settle = (reply: RawProbeReply) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (this._pending?.id === id) { + this._pending = undefined; + } + resolve(reply); + }; + const timer = setTimeout(() => settle({ kind: 'timeout' }), timeoutMs); + this._pending = { id, resolve: settle }; + this._transport.send(buildRequest(id)).catch((error: unknown) => settle({ kind: 'send-error', error })); + }); + } + + /** Detach the window's handlers, leaving the transport's own `start` untouched. */ + detach(): void { + this._pending = undefined; + this._transport.onmessage = undefined; + this._transport.onerror = undefined; + this._transport.onclose = undefined; + } + + /** Detach the handlers and arm the one-shot `start()` pass-through for the `Protocol.connect()` handover. */ + release(): void { + this.detach(); + const transport = this._transport; + const originalStart = transport.start.bind(transport); + let armed = true; + transport.start = async (): Promise => { + if (armed) { + armed = false; + transport.start = originalStart; + return; + } + return originalStart(); + }; + } +} + +/** Build the probe request: `server/discover` carrying the full per-request `_meta` envelope. */ +export function buildProbeRequest( + id: string, + protocolVersion: string, + clientInfo: Implementation, + capabilities: ClientCapabilities +): JSONRPCRequest { + return { + jsonrpc: '2.0', + id, + method: 'server/discover', + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: protocolVersion, + [CLIENT_INFO_META_KEY]: clientInfo, + [CLIENT_CAPABILITIES_META_KEY]: capabilities + } + } + }; +} + +function normalizeReply(reply: RawProbeReply, timeoutMs: number): ProbeOutcome { + switch (reply.kind) { + case 'response': { + return reply.error === undefined ? { kind: 'result', result: reply.result } : { kind: 'rpc-error', ...reply.error }; + } + case 'send-error': { + const error = reply.error; + if (error instanceof SdkHttpError) { + const text = (error.data as { text?: unknown } | undefined)?.text; + return { kind: 'http-error', status: error.data.status, body: typeof text === 'string' ? text : undefined }; + } + if (error instanceof Error && error.name === 'UnauthorizedError') { + // Auth-gated server: not era evidence — the conservative legacy + // fallback re-runs the auth flow through the plain connect path. + return { kind: 'http-error', status: 401 }; + } + return { kind: 'network-error', error }; + } + case 'closed': { + return { kind: 'network-error', error: new Error('Connection closed during the version negotiation probe') }; + } + case 'timeout': { + return { kind: 'timeout', timeoutMs }; + } + } +} + +export interface NegotiationDeps { + transport: Transport; + clientInfo: Implementation; + capabilities: ClientCapabilities; + environment: ProbeEnvironment; + /** The transport class, for the transport-aware timeout verdict (see {@linkcode ProbeTransportKind}). */ + transportKind: ProbeTransportKind; + /** The standard request timeout for this connect (probe inherits it unless `probe.timeoutMs` overrides). */ + defaultTimeoutMs: number; +} + +export type NegotiationResult = { era: 'modern'; version: string; discover: DiscoverResult } | { era: 'legacy' }; + +/** + * Run the negotiation probe state machine on a raw (not yet Protocol-connected) + * transport. Resolves with the negotiated era; throws typed connect errors. On + * return the probe window has been released: the transport is started, + * handler-free, and ready for `Protocol.connect()` handover. On throw the + * window is detached and the transport's `start` is left untouched. + */ +export async function negotiateEra( + negotiation: Extract, + deps: NegotiationDeps +): Promise { + const timeoutMs = negotiation.probe.timeoutMs ?? deps.defaultTimeoutMs; + const clientModernVersions = negotiation.kind === 'pin' ? [negotiation.version] : negotiation.modernVersions; + const fallbackAvailable = negotiation.kind === 'auto' && negotiation.fallbackAvailable; + + const window = await ProbeWindow.open(deps.transport); + + const probe = async (): Promise => { + let requestedVersion = clientModernVersions[0]!; + // The -32004 corrective continuation runs exactly once (even when the + // mutual version equals the just-rejected one); the loop guard arms on + // the second rejection. + let correctiveUsed = false; + for (;;) { + const reply = await window.exchange( + id => buildProbeRequest(id, requestedVersion, deps.clientInfo, deps.capabilities), + timeoutMs + ); + + const outcome = normalizeReply(reply, timeoutMs); + const verdict: ProbeVerdict = classifyProbeOutcome(outcome, { + clientModernVersions, + requestedVersion, + fallbackAvailable, + environment: deps.environment, + transportKind: deps.transportKind + }); + + switch (verdict.kind) { + case 'modern': { + return { era: 'modern', version: verdict.version, discover: verdict.discover }; + } + case 'corrective': { + if (correctiveUsed) { + // Second rejection: loop guard. + throw verdict.error; + } + correctiveUsed = true; + requestedVersion = verdict.version; + continue; + } + case 'legacy': { + if (negotiation.kind === 'pin') { + throw new SdkError( + SdkErrorCode.EraNegotiationFailed, + `Version negotiation failed: the server did not offer pinned protocol version ${negotiation.version} ` + + `via server/discover (no fallback in pin mode)` + ); + } + if (!negotiation.fallbackAvailable) { + // Modern-only client: the legacy initialize fallback is + // unavailable and must never carry a 2026-era version string. + throw new SdkError( + SdkErrorCode.EraNegotiationFailed, + 'Version negotiation failed: the server gave no modern evidence and this client supports no ' + + 'pre-2026-07-28 protocol version to fall back to' + ); + } + return { era: 'legacy' }; + } + case 'error': { + throw verdict.error; + } + } + } + }; + + let result: NegotiationResult; + try { + result = await probe(); + } catch (error) { + // A failed negotiation leaves the transport exactly as it found it: + // handlers detached, original start untouched (no pass-through armed). + window.detach(); + throw error; + } + window.release(); + return result; +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 8a08e8fd79..42fc132c2a 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -61,6 +61,7 @@ export type { LoggingOptions, Middleware, RequestLogger } from './client/middlew export { applyMiddlewares, createMiddleware, withLogging, withOAuth } from './client/middleware.js'; export type { SSEClientTransportOptions } from './client/sse.js'; export { SSEClientTransport, SseError } from './client/sse.js'; +export type { VersionNegotiationMode, VersionNegotiationOptions, VersionNegotiationProbeOptions } from './client/versionNegotiation.js'; // StdioClientTransport, getDefaultEnvironment, DEFAULT_INHERITED_ENV_VARS, StdioServerParameters are exported from // the './stdio' subpath to keep the root entry free of process-spawning runtime dependencies (child_process, cross-spawn). export type { diff --git a/packages/client/test/client/bodyDerivedProbeHeaders.test.ts b/packages/client/test/client/bodyDerivedProbeHeaders.test.ts new file mode 100644 index 0000000000..de886f61e0 --- /dev/null +++ b/packages/client/test/client/bodyDerivedProbeHeaders.test.ts @@ -0,0 +1,128 @@ +/** + * Body-derived per-request headers on the streamable HTTP client transport: + * when a single outgoing request carries the 2026-07-28 protocol-version claim + * in its `_meta` envelope (the negotiation probe is the first such sender), the + * `MCP-Protocol-Version` and `Mcp-Method` headers derive from the message + * itself. The connection-level version slot is never consulted or mutated for + * those sends, and envelope-less (2025-era) traffic gets no new headers. + */ +import type { JSONRPCMessage } from '@modelcontextprotocol/core'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp.js'; + +describe('body-derived probe headers', () => { + let transport: StreamableHTTPClientTransport; + let fetchSpy: ReturnType; + + const okJson = (body: unknown) => ({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: () => Promise.resolve(body) + }); + + beforeEach(async () => { + fetchSpy = vi.fn(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + await transport.start(); + }); + + afterEach(async () => { + await transport.close().catch(() => {}); + vi.restoreAllMocks(); + }); + + const probeRequest: JSONRPCMessage = { + jsonrpc: '2.0', + id: 'server-discover-probe-1', + method: 'server/discover', + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: '2026-07-28', + [CLIENT_INFO_META_KEY]: { name: 'c', version: '0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + } + } + }; + + const sentHeaders = (): Headers => { + const init = fetchSpy.mock.calls.at(-1)?.[1] as RequestInit; + return init.headers as Headers; + }; + + it('derives MCP-Protocol-Version and Mcp-Method from the probe message body', async () => { + fetchSpy.mockResolvedValueOnce( + okJson({ jsonrpc: '2.0', id: 'server-discover-probe-1', result: { supportedVersions: ['2026-07-28'] } }) + ); + + await transport.send(probeRequest); + + const headers = sentHeaders(); + expect(headers.get('mcp-protocol-version')).toBe('2026-07-28'); + expect(headers.get('mcp-method')).toBe('server/discover'); + }); + + it('never mutates the transport version slot for body-derived sends', async () => { + fetchSpy.mockResolvedValueOnce( + okJson({ jsonrpc: '2.0', id: 'server-discover-probe-1', result: { supportedVersions: ['2026-07-28'] } }) + ); + + await transport.send(probeRequest); + expect(transport.protocolVersion).toBeUndefined(); + + // A follow-up envelope-less message gets no version header at all — the + // slot is still unset; nothing leaked from the probe. + fetchSpy.mockResolvedValueOnce(okJson({ jsonrpc: '2.0', id: 0, result: {} })); + await transport.send({ jsonrpc: '2.0', id: 0, method: 'ping', params: {} }); + + const headers = sentHeaders(); + expect(headers.get('mcp-protocol-version')).toBeNull(); + expect(headers.get('mcp-method')).toBeNull(); + }); + + it('envelope-less (2025-era) requests are untouched: no 2026 headers, slot-driven behavior unchanged', async () => { + fetchSpy.mockResolvedValueOnce(okJson({ jsonrpc: '2.0', id: 1, result: {} })); + await transport.send({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} }); + + let headers = sentHeaders(); + expect(headers.get('mcp-protocol-version')).toBeNull(); + expect(headers.get('mcp-method')).toBeNull(); + expect(headers.get('mcp-name')).toBeNull(); + + // setProtocolVersion (the legacy post-initialize call site, byte-untouched) + // still drives the header for subsequent slot-based sends. + transport.setProtocolVersion('2025-11-25'); + fetchSpy.mockResolvedValueOnce(okJson({ jsonrpc: '2.0', id: 2, result: {} })); + await transport.send({ jsonrpc: '2.0', id: 2, method: 'ping', params: {} }); + + headers = sentHeaders(); + expect(headers.get('mcp-protocol-version')).toBe('2025-11-25'); + expect(headers.get('mcp-method')).toBeNull(); + }); + + it('a body-derived claim takes precedence over the slot for its own request only', async () => { + transport.setProtocolVersion('2025-11-25'); + + fetchSpy.mockResolvedValueOnce( + okJson({ jsonrpc: '2.0', id: 'server-discover-probe-1', result: { supportedVersions: ['2026-07-28'] } }) + ); + await transport.send(probeRequest); + expect(sentHeaders().get('mcp-protocol-version')).toBe('2026-07-28'); + + fetchSpy.mockResolvedValueOnce(okJson({ jsonrpc: '2.0', id: 3, result: {} })); + await transport.send({ jsonrpc: '2.0', id: 3, method: 'ping', params: {} }); + expect(sentHeaders().get('mcp-protocol-version')).toBe('2025-11-25'); + }); + + it('batch (array) sends are never body-derived', async () => { + fetchSpy.mockResolvedValueOnce(okJson([{ jsonrpc: '2.0', id: 4, result: {} }])); + await transport.send([probeRequest as never]); + + const headers = sentHeaders(); + expect(headers.get('mcp-protocol-version')).toBeNull(); + expect(headers.get('mcp-method')).toBeNull(); + }); +}); diff --git a/packages/client/test/client/clientTypeSurface.test.ts b/packages/client/test/client/clientTypeSurface.test.ts new file mode 100644 index 0000000000..c6246a8fed --- /dev/null +++ b/packages/client/test/client/clientTypeSurface.test.ts @@ -0,0 +1,30 @@ +/** + * Type-surface pins for the client's high-level methods. + * + * `callTool` returns plain `CallToolResult` on every protocol era — no task + * union (a v2 client never sends a task-augmented call, so a task result is + * unreachable from its API) and no wire-only members (`resultType` is + * consumed at the protocol layer and never reaches consumers). + */ +import type { CallToolResult, EmptyResult, ListToolsResult, ReadResourceResult } from '@modelcontextprotocol/core'; +import { describe, expectTypeOf, test } from 'vitest'; + +import { Client } from '../../src/client/client.js'; + +type KnownKeyOf = keyof { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] }; + +describe('client method return types', () => { + test('callTool returns plain CallToolResult (no union, no wire-only members)', () => { + type Return = Awaited>; + expectTypeOf().toEqualTypeOf(); + expectTypeOf, 'resultType'>>().toEqualTypeOf(); + expectTypeOf, 'task'>>().toEqualTypeOf(); + }); + + test('the other request methods return the public result types', () => { + expectTypeOf>>().toEqualTypeOf(); + expectTypeOf>>().toEqualTypeOf(); + expectTypeOf>>().toEqualTypeOf(); + expectTypeOf>>, 'resultType'>>().toEqualTypeOf(); + }); +}); diff --git a/packages/client/test/client/discover.test.ts b/packages/client/test/client/discover.test.ts new file mode 100644 index 0000000000..9e971f1cc5 --- /dev/null +++ b/packages/client/test/client/discover.test.ts @@ -0,0 +1,101 @@ +/** + * Typed `Client.discover()`: issues `server/discover` through the typed + * request funnel on a 2026-era connection; on a 2025-era connection the + * method does not exist (it is absent from the legacy registry), so the + * outbound era gate rejects it locally with a typed error before anything + * reaches the transport. + */ +import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; +import { isJSONRPCRequest, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import { describe, expect, test } from 'vitest'; + +import { Client } from '../../src/client/client.js'; + +const MODERN = '2026-07-28'; + +class ScriptedTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + sessionId?: string; + sent: JSONRPCMessage[] = []; + + constructor(private readonly script: (message: JSONRPCMessage, transport: ScriptedTransport) => void) {} + + async start(): Promise {} + async close(): Promise { + this.onclose?.(); + } + async send(message: JSONRPCMessage): Promise { + this.sent.push(message); + queueMicrotask(() => this.script(message, this)); + } + setProtocolVersion(_version: string): void {} + reply(message: JSONRPCMessage): void { + this.onmessage?.(message); + } +} + +const discoverBody = { + // A real 2026-era server stamps the resultType discriminator on the wire, + // and the 2026 wire shape carries the cacheable-result fields. + resultType: 'complete', + ttlMs: 0, + cacheScope: 'public', + supportedVersions: [MODERN], + capabilities: { tools: {} }, + serverInfo: { name: 'modern-server', version: '1.0.0' }, + instructions: 'modern instructions' +}; + +/** Answers server/discover (probe and typed request alike) like a modern server. */ +const modernScript = (message: JSONRPCMessage, t: ScriptedTransport) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + t.reply({ jsonrpc: '2.0', id: message.id, result: discoverBody }); + } +}; + +/** A plain 2025 server: answers initialize, -32601 for everything else. */ +const legacyScript = (message: JSONRPCMessage, t: ScriptedTransport) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'initialize') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + result: { protocolVersion: '2025-11-25', capabilities: {}, serverInfo: { name: 'legacy-server', version: '1.0.0' } } + }); + } else { + t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } }); + } +}; + +describe('Client.discover()', () => { + test('issues a typed server/discover request on a 2026-era connection', async () => { + const transport = new ScriptedTransport(modernScript); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(transport); + + const advertisement = await client.discover(); + expect(advertisement.supportedVersions).toEqual([MODERN]); + expect(advertisement.serverInfo).toEqual({ name: 'modern-server', version: '1.0.0' }); + expect(advertisement.instructions).toBe('modern instructions'); + + await client.close(); + }); + + test('is rejected locally with a typed error on a 2025-era connection (the method does not exist on that era)', async () => { + const transport = new ScriptedTransport(legacyScript); + const client = new Client({ name: 'c', version: '0' }); + await client.connect(transport); + + const sentBefore = transport.sent.length; + await expect(client.discover()).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.MethodNotSupportedByProtocolVersion + ); + // Rejected locally: nothing new reached the transport. + expect(transport.sent.length).toBe(sentBefore); + + await client.close(); + }); +}); diff --git a/packages/client/test/client/legacyHandshakeModernOnlyGuard.test.ts b/packages/client/test/client/legacyHandshakeModernOnlyGuard.test.ts new file mode 100644 index 0000000000..cf6c34af2b --- /dev/null +++ b/packages/client/test/client/legacyHandshakeModernOnlyGuard.test.ts @@ -0,0 +1,47 @@ +/** + * Plain-path guard for modern-only supported-versions lists: a Client + * constructed WITHOUT versionNegotiation must never offer a 2026-era revision + * through the legacy `initialize` handshake. With no 2025-era entry to offer, + * connect() rejects with the typed negotiation error before anything reaches + * the wire — independently of the same guard on the auto-negotiation path. + */ +import type { JSONRPCMessage, MessageExtraInfo, Transport } from '@modelcontextprotocol/core'; +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import { describe, expect, test } from 'vitest'; + +import { Client } from '../../src/client/client.js'; + +function recordingTransport(): Transport & { sent: JSONRPCMessage[] } { + const sent: JSONRPCMessage[] = []; + return { + sent, + async start() { + // nothing to start + }, + async send(message: JSONRPCMessage) { + sent.push(message); + }, + async close() { + // nothing to close + }, + onclose: undefined, + onerror: undefined, + onmessage: undefined as ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined + }; +} + +describe('plain client with a modern-only supported-versions list', () => { + test.each([ + { label: "['2026-07-28']", supportedProtocolVersions: ['2026-07-28'] }, + { label: '[] (empty list)', supportedProtocolVersions: [] as string[] } + ])('connect() rejects with the typed negotiation error and never sends initialize — $label', async ({ supportedProtocolVersions }) => { + const transport = recordingTransport(); + const client = new Client({ name: 'modern-only-client', version: '1.0.0' }, { supportedProtocolVersions }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + + expect(transport.sent.filter(message => 'method' in message && message.method === 'initialize')).toHaveLength(0); + }); +}); diff --git a/packages/client/test/client/modernEraInboundDrop.test.ts b/packages/client/test/client/modernEraInboundDrop.test.ts new file mode 100644 index 0000000000..c23f5d1614 --- /dev/null +++ b/packages/client/test/client/modernEraInboundDrop.test.ts @@ -0,0 +1,109 @@ +/** + * TS-01 directionality, client side: the 2026-07-28 era has no server→client + * JSON-RPC request channel, and on stdio the client must never write JSON-RPC + * responses — so an inbound request arriving on a connection that negotiated + * a modern era is dropped (surfaced via `onerror`), never answered. Legacy-era + * connections keep today's behavior (the client answers, e.g. with −32601 for + * methods it has no handler for). + */ +import type { JSONRPCMessage } from '@modelcontextprotocol/core'; +import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { Client } from '../../src/client/client.js'; + +const MODERN = '2026-07-28'; + +const flush = () => new Promise(resolve => setTimeout(resolve, 20)); + +/** + * A scripted server side of an in-memory pair: answers `server/discover` (so a + * negotiating client lands on the modern era) or `initialize` (legacy era), and + * records everything the client writes. + */ +async function scriptedServerSide(eras: 'modern' | 'legacy') { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + serverTx.onmessage = message => { + written.push(message); + const request = message as { id?: number | string; method?: string }; + if (request.method === 'server/discover' && request.id !== undefined) { + if (eras === 'modern') { + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: {} }, + serverInfo: { name: 'scripted-modern-server', version: '1.0.0' } + } + }); + } else { + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + error: { code: -32_601, message: 'Method not found' } + }); + } + return; + } + if (request.method === 'initialize' && request.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + serverInfo: { name: 'scripted-legacy-server', version: '1.0.0' } + } + }); + } + }; + await serverTx.start(); + return { clientTx, serverTx, written }; +} + +describe('client inbound-drop on modern-era connections (TS-01)', () => { + it('drops an inbound server→client request without writing any response, surfacing it via onerror', async () => { + const { clientTx, serverTx, written } = await scriptedServerSide('modern'); + const client = new Client({ name: 'drop-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + const errors: Error[] = []; + client.onerror = error => void errors.push(error); + await client.connect(clientTx); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + const before = written.length; + // A misbehaving "modern" server sends a server→client request (the + // channel is deleted in the 2026 era). The client must not answer. + await serverTx.send({ + jsonrpc: '2.0', + id: 'rogue-1', + method: 'roots/list', + params: {} + }); + await flush(); + + expect(written).toHaveLength(before); + expect(errors.some(error => error.message.includes('Dropped inbound request'))).toBe(true); + + await client.close(); + }); + + it('keeps answering inbound requests on legacy-era connections (control arm)', async () => { + const { clientTx, serverTx, written } = await scriptedServerSide('legacy'); + const client = new Client({ name: 'legacy-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + expect(client.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + + await serverTx.send({ jsonrpc: '2.0', id: 'legacy-1', method: 'roots/list', params: {} }); + await flush(); + + // Today's behavior: the client answers (here −32601, no roots handler installed). + const answer = written.find(message => (message as { id?: string }).id === 'legacy-1'); + expect(answer).toBeDefined(); + expect((answer as { error?: { code: number } }).error?.code).toBe(-32_601); + + await client.close(); + }); +}); diff --git a/packages/client/test/client/probeClassifier.test.ts b/packages/client/test/client/probeClassifier.test.ts new file mode 100644 index 0000000000..f442f65fb0 --- /dev/null +++ b/packages/client/test/client/probeClassifier.test.ts @@ -0,0 +1,306 @@ +/** + * Row-by-row tests for the merged probe-outcome classifier table. + * + * Each `describe` block names the row of the adjudicated table it covers. The + * HTTP-shaped fixtures mirror the exact bodies deployed servers emit + * (`createJsonErrorResponse`: `{"jsonrpc":"2.0","error":{...},"id":null}`); the + * end-to-end capture of the same shapes from real server transports lives in + * test/integration/test/client/versionNegotiation.test.ts. + */ +import { SdkError, SdkErrorCode, UnsupportedProtocolVersionError } from '@modelcontextprotocol/core'; +import { describe, expect, test } from 'vitest'; + +import type { ProbeClassifierContext, ProbeOutcome, ProbeVerdict } from '../../src/client/probeClassifier.js'; +import { classifyProbeOutcome } from '../../src/client/probeClassifier.js'; + +const MODERN = '2026-07-28'; +const LEGACY = '2025-11-25'; + +const baseContext: ProbeClassifierContext = { + clientModernVersions: [MODERN], + requestedVersion: MODERN, + fallbackAvailable: true, + environment: 'node', + transportKind: 'http' +}; + +function classify(outcome: ProbeOutcome, context: Partial = {}): ProbeVerdict { + return classifyProbeOutcome(outcome, { ...baseContext, ...context }); +} + +const discoverResult = (supportedVersions: string[]) => ({ + supportedVersions, + capabilities: { tools: {} }, + serverInfo: { name: 'fixture-server', version: '1.0.0' } +}); + +/** The deployed-fleet 400 body for a JSON-RPC error (server streamableHttp `createJsonErrorResponse`). */ +const httpErrorBody = (code: number, message: string, data?: unknown) => + JSON.stringify({ jsonrpc: '2.0', error: data === undefined ? { code, message } : { code, message, data }, id: null }); + +describe('row: DiscoverResult with version overlap → modern, select from supportedVersions', () => { + test('selects the mutual modern version', () => { + const verdict = classify({ kind: 'result', result: discoverResult([MODERN, '2027-01-01']) }); + expect(verdict).toMatchObject({ kind: 'modern', version: MODERN }); + }); + + test('selection follows the client preference order', () => { + const verdict = classify( + { kind: 'result', result: discoverResult(['2027-01-01', MODERN]) }, + { clientModernVersions: ['2027-01-01', MODERN] } + ); + expect(verdict).toMatchObject({ kind: 'modern', version: '2027-01-01' }); + }); + + test('carries the parsed DiscoverResult for connection state', () => { + const verdict = classify({ kind: 'result', result: discoverResult([MODERN]) }); + expect(verdict.kind).toBe('modern'); + if (verdict.kind === 'modern') { + expect(verdict.discover.capabilities).toEqual({ tools: {} }); + expect(verdict.discover.serverInfo.name).toBe('fixture-server'); + } + }); +}); + +describe('row: DiscoverResult with NO overlap → initialize on the same connection, else typed error with synthesized data', () => { + test('fallback possible → legacy (era selection on a dual-era server)', () => { + const verdict = classify({ kind: 'result', result: discoverResult(['2027-12-31']) }); + expect(verdict).toEqual({ kind: 'legacy' }); + }); + + test('fallback impossible → typed UnsupportedProtocolVersionError with synthesized data', () => { + const verdict = classify({ kind: 'result', result: discoverResult(['2027-12-31']) }, { fallbackAvailable: false }); + expect(verdict.kind).toBe('error'); + if (verdict.kind === 'error') { + expect(verdict.error).toBeInstanceOf(UnsupportedProtocolVersionError); + const error = verdict.error as UnsupportedProtocolVersionError; + expect(error.supported).toEqual(['2027-12-31']); + expect(error.requested).toBe(MODERN); + } + }); +}); + +describe('row: -32004 + valid data.supported with a mutual modern version → select-and-continue, MUST NOT fall back', () => { + test('in-band -32004 yields a corrective verdict (never legacy)', () => { + const verdict = classify({ + kind: 'rpc-error', + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: [MODERN], requested: '2027-01-01' } + }); + expect(verdict).toMatchObject({ kind: 'corrective', version: MODERN }); + }); + + test('HTTP 400-bodied -32004 yields the same corrective verdict', () => { + const verdict = classify({ + kind: 'http-error', + status: 400, + body: httpErrorBody(-32_004, 'Unsupported protocol version', { supported: [MODERN], requested: MODERN }) + }); + expect(verdict).toMatchObject({ kind: 'corrective', version: MODERN }); + }); + + test('corrective even when the mutual version equals the just-rejected one (T2/A6 — caller runs it exactly once)', () => { + const verdict = classify({ + kind: 'rpc-error', + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: [MODERN], requested: MODERN } + }); + expect(verdict).toMatchObject({ kind: 'corrective', version: MODERN }); + if (verdict.kind === 'corrective') { + expect(verdict.error).toBeInstanceOf(UnsupportedProtocolVersionError); + } + }); +}); + +describe('row: -32004 with a disjoint-but-modern list → typed error, never initialize', () => { + test('no mutual modern version but the list is modern', () => { + const verdict = classify({ + kind: 'rpc-error', + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: ['2027-12-31'], requested: MODERN } + }); + expect(verdict.kind).toBe('error'); + if (verdict.kind === 'error') { + expect(verdict.error).toBeInstanceOf(UnsupportedProtocolVersionError); + expect((verdict.error as UnsupportedProtocolVersionError).supported).toEqual(['2027-12-31']); + } + }); +}); + +describe('row: -32004 with a legacy-only list → initialize; modern-only client → typed error carrying data.supported', () => { + test('legacy-only list with fallback available → legacy', () => { + const verdict = classify({ + kind: 'rpc-error', + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: [LEGACY, '2025-06-18'] } + }); + expect(verdict).toEqual({ kind: 'legacy' }); + }); + + test('legacy-only list, modern-only client → typed error carrying data.supported', () => { + const verdict = classify( + { kind: 'rpc-error', code: -32_004, message: 'Unsupported protocol version', data: { supported: [LEGACY] } }, + { fallbackAvailable: false } + ); + expect(verdict.kind).toBe('error'); + if (verdict.kind === 'error') { + expect((verdict.error as UnsupportedProtocolVersionError).supported).toEqual([LEGACY]); + expect((verdict.error as UnsupportedProtocolVersionError).requested).toBe(MODERN); + } + }); + + test('-32004 with malformed data (no valid supported list) → conservative legacy', () => { + expect(classify({ kind: 'rpc-error', code: -32_004, message: 'nope', data: { supported: 'not-a-list' } })).toEqual({ + kind: 'legacy' + }); + expect(classify({ kind: 'rpc-error', code: -32_004, message: 'nope' })).toEqual({ kind: 'legacy' }); + }); +}); + +describe('row: -32601 → legacy (never modern evidence on the probe, including 200-bodied errors)', () => { + test('in-band -32601 (stdio / 200-bodied HTTP)', () => { + expect(classify({ kind: 'rpc-error', code: -32_601, message: 'Method not found' })).toEqual({ kind: 'legacy' }); + }); + + test('HTTP 404-bodied -32601', () => { + expect(classify({ kind: 'http-error', status: 404, body: httpErrorBody(-32_601, 'Method not found') })).toEqual({ + kind: 'legacy' + }); + }); +}); + +describe('row: 400 + -32000 "Unsupported protocol version" literal (deployed TS-SDK fleet, stateless) → legacy', () => { + test('the byte-real literal body', () => { + // Fixture mirrors server/streamableHttp.ts validateProtocolVersion — the + // Q10-L1 frozen literal, consumed here as a fixture only. + const body = httpErrorBody( + -32_000, + `Bad Request: Unsupported protocol version: ${MODERN} (supported versions: 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07)` + ); + expect(classify({ kind: 'http-error', status: 400, body })).toEqual({ kind: 'legacy' }); + }); +}); + +describe('row: 400 + -32000 free-text (stateful session-required shapes) → legacy', () => { + test('"Server not initialized" (stateful first contact; session is checked before version)', () => { + expect(classify({ kind: 'http-error', status: 400, body: httpErrorBody(-32_000, 'Bad Request: Server not initialized') })).toEqual({ + kind: 'legacy' + }); + }); + + test('"Mcp-Session-Id header is required"', () => { + expect( + classify({ + kind: 'http-error', + status: 400, + body: httpErrorBody(-32_000, 'Bad Request: Mcp-Session-Id header is required') + }) + ).toEqual({ kind: 'legacy' }); + }); + + test('in-band -32000 free-text', () => { + expect(classify({ kind: 'rpc-error', code: -32_000, message: 'Server not initialized' })).toEqual({ kind: 'legacy' }); + }); +}); + +describe('row: plain-text/unparseable 400, code 0, empty body, 406, any unrecognized shape → legacy (conservative D4)', () => { + test('plain-text 400', () => { + expect(classify({ kind: 'http-error', status: 400, body: 'Bad Request' })).toEqual({ kind: 'legacy' }); + }); + + test('JSON-RPC error with code 0', () => { + expect(classify({ kind: 'rpc-error', code: 0, message: 'weird' })).toEqual({ kind: 'legacy' }); + expect(classify({ kind: 'http-error', status: 400, body: httpErrorBody(0, 'weird') })).toEqual({ kind: 'legacy' }); + }); + + test('empty body', () => { + expect(classify({ kind: 'http-error', status: 400, body: '' })).toEqual({ kind: 'legacy' }); + expect(classify({ kind: 'http-error', status: 400 })).toEqual({ kind: 'legacy' }); + }); + + test('406 Not Acceptable', () => { + expect(classify({ kind: 'http-error', status: 406, body: 'Not Acceptable: Client must accept text/event-stream' })).toEqual({ + kind: 'legacy' + }); + }); + + test('unrecognized 200 result shape (era-ambiguous first-request processing)', () => { + expect(classify({ kind: 'result', result: { ok: true } })).toEqual({ kind: 'legacy' }); + }); +}); + +describe('row: -32001 / -32003 are NEVER probe-recognized → fall into unrecognized → legacy', () => { + test('-32001 (session-404 overload on deployed servers; the spec-assigned HeaderMismatch code is still never probe evidence)', () => { + expect(classify({ kind: 'rpc-error', code: -32_001, message: 'Session not found' })).toEqual({ kind: 'legacy' }); + expect(classify({ kind: 'http-error', status: 404, body: httpErrorBody(-32_001, 'Session not found') })).toEqual({ + kind: 'legacy' + }); + }); + + test('-32003 with data is NOT modern evidence', () => { + expect(classify({ kind: 'rpc-error', code: -32_003, message: 'Capability required', data: { capability: 'sampling' } })).toEqual({ + kind: 'legacy' + }); + }); +}); + +describe('row: network outage → typed connect error (Node)', () => { + test('connection refused is never an era verdict', () => { + const cause = Object.assign(new Error('fetch failed'), { code: 'ECONNREFUSED' }); + const verdict = classify({ kind: 'network-error', error: cause }); + expect(verdict.kind).toBe('error'); + if (verdict.kind === 'error') { + expect(verdict.error).toBeInstanceOf(SdkError); + expect((verdict.error as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + } + }); + + test('a Node TypeError (no CORS layer) is still a typed connect error', () => { + const verdict = classify({ kind: 'network-error', error: new TypeError('fetch failed') }, { environment: 'node' }); + expect(verdict.kind).toBe('error'); + }); +}); + +describe('row: timeout — transport-aware verdict', () => { + // The specification's backward-compatibility rule for stdio: "any other + // error, or does not respond within a reasonable timeout: the server is + // legacy. Fall back to the initialize handshake." The versioning + // compatibility matrix draws the same line per transport: stdio probe + // times out → fall back to initialize; on HTTP the legacy signal is a 4xx + // without a recognized modern error body, so silence stays an outage. + test('HTTP: timeout maps to the standard RequestTimeout SdkError (silence on a deployed server is an outage)', () => { + const verdict = classify({ kind: 'timeout', timeoutMs: 60_000 }, { transportKind: 'http' }); + expect(verdict.kind).toBe('error'); + if (verdict.kind === 'error') { + expect(verdict.error).toBeInstanceOf(SdkError); + expect((verdict.error as SdkError).code).toBe(SdkErrorCode.RequestTimeout); + } + }); + + test('stdio: timeout is a legacy-server signal → fall back to initialize on the same stream', () => { + expect(classify({ kind: 'timeout', timeoutMs: 5_000 }, { transportKind: 'stdio' })).toEqual({ kind: 'legacy' }); + }); +}); + +describe('row: browser opaque CORS/preflight TypeError, PROBE PHASE ONLY → legacy fallback (F-7)', () => { + test('browser environment + bare TypeError → legacy', () => { + expect(classify({ kind: 'network-error', error: new TypeError('Failed to fetch') }, { environment: 'browser' })).toEqual({ + kind: 'legacy' + }); + }); + + test('cross-realm TypeError (name-based recognition) → legacy in a browser', () => { + const foreign = new Error('Failed to fetch'); + foreign.name = 'TypeError'; + expect(classify({ kind: 'network-error', error: foreign }, { environment: 'browser' })).toEqual({ kind: 'legacy' }); + }); + + test('browser non-TypeError network failure stays a typed connect error', () => { + const verdict = classify({ kind: 'network-error', error: new Error('socket hang up') }, { environment: 'browser' }); + expect(verdict.kind).toBe('error'); + }); +}); diff --git a/packages/client/test/client/probeFixtureCorpus.test.ts b/packages/client/test/client/probeFixtureCorpus.test.ts new file mode 100644 index 0000000000..2e46b5faea --- /dev/null +++ b/packages/client/test/client/probeFixtureCorpus.test.ts @@ -0,0 +1,231 @@ +/** + * Merged first-contact fixture corpus (T9 probe edges ∪ wire-real shapes) + * binding the two pure modules of the negotiation path: + * + * - the probe-outcome classifier (`classifyProbeOutcome`): the five T9 probe + * edges (plain-text 400; JSON-RPC `code: 0`; probe-success-then-no-overlap + * → initialize on the SAME connection; legacy servers that 200-process + * era-ambiguous first requests; numeric-id collision avoidance via a string + * probe id) merged with the wire-real first-contact shapes a deployed 2025 + * TypeScript server actually answers (the −32000 "Unsupported protocol + * version" literal and the 400/−32000 session-required body). Recognition + * is a typed allowlist — codes and structured data — never message-text + * sniffing. + * - the era predicate's per-message form is bound by + * `packages/core/test/shared/classifyInboundMessage.test.ts` (T11). + * + * Probe RUNTIME (timeout/retry policy and the connect loop) is covered by the + * negotiation engine suites; this corpus pins classification only, plus the + * probe wire shape (string id, `server/discover` first, never a real request). + */ +import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; +import { LATEST_PROTOCOL_VERSION, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { Client } from '../../src/client/client.js'; +import type { ProbeClassifierContext, ProbeOutcome, ProbeVerdict } from '../../src/client/probeClassifier.js'; +import { classifyProbeOutcome } from '../../src/client/probeClassifier.js'; + +const MODERN = '2026-07-28'; + +const baseContext: ProbeClassifierContext = { + clientModernVersions: [MODERN], + requestedVersion: MODERN, + fallbackAvailable: true, + environment: 'node', + transportKind: 'stdio' +}; + +/** The byte-exact first-contact literal a deployed 2025 stateless server answers a modern probe with. */ +const DEPLOYED_UNSUPPORTED_VERSION_BODY = JSON.stringify({ + jsonrpc: '2.0', + id: null, + error: { + code: -32_000, + message: `Bad Request: Unsupported protocol version: ${MODERN} (supported versions: 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07)` + } +}); + +/** The session-required free-text shape a deployed stateful server answers a session-less probe with. */ +const DEPLOYED_SESSION_REQUIRED_BODY = JSON.stringify({ + jsonrpc: '2.0', + id: null, + error: { code: -32_000, message: 'Bad Request: Server not initialized' } +}); + +interface CorpusRow { + name: string; + outcome: ProbeOutcome; + context?: Partial; + expected: ProbeVerdict['kind']; +} + +const CORPUS: CorpusRow[] = [ + // --- T9 edge 1: plain-text 400 (no JSON-RPC body at all). + { + name: 'T9: plain-text HTTP 400 → legacy fallback', + outcome: { kind: 'http-error', status: 400, body: 'Bad Request' }, + expected: 'legacy' + }, + // --- T9 edge 2: JSON-RPC error with code 0. + { + name: 'T9: JSON-RPC error code 0 → legacy fallback', + outcome: { kind: 'rpc-error', code: 0, message: 'unknown method' }, + expected: 'legacy' + }, + // --- T9 edge 3: probe success but no version overlap → initialize on the SAME connection. + { + name: 'T9: DiscoverResult with no mutual version + fallback available → legacy (initialize on the same connection)', + outcome: { + kind: 'result', + result: { supportedVersions: ['2027-01-01'], capabilities: {}, serverInfo: { name: 's', version: '1' } } + }, + expected: 'legacy' + }, + { + name: 'T9: DiscoverResult with no mutual version + NO fallback (pin / modern-only) → typed error, never initialize', + outcome: { + kind: 'result', + result: { supportedVersions: ['2027-01-01'], capabilities: {}, serverInfo: { name: 's', version: '1' } } + }, + context: { fallbackAvailable: false }, + expected: 'error' + }, + // --- T9 edge 4: a legacy server that 200-processes an era-ambiguous first request. + // The probe is server/discover precisely so this comes back as an + // unrecognized result shape (never a DiscoverResult) and stays legacy. + { + name: 'T9: 200-processed era-ambiguous result (not a DiscoverResult) → legacy fallback', + outcome: { kind: 'result', result: { tools: [{ name: 'echo', inputSchema: { type: 'object' } }] } }, + expected: 'legacy' + }, + // --- Wire-real shape A: the deployed −32000 unsupported-protocol-version literal (HTTP 400). + { + name: 'wire-real: HTTP 400 with the deployed -32000 "Unsupported protocol version" literal → legacy fallback', + outcome: { kind: 'http-error', status: 400, body: DEPLOYED_UNSUPPORTED_VERSION_BODY }, + expected: 'legacy' + }, + // --- Wire-real shape B: the deployed 400/−32000 session-required free text. + { + name: 'wire-real: HTTP 400 with the deployed -32000 session-required body → legacy fallback', + outcome: { kind: 'http-error', status: 400, body: DEPLOYED_SESSION_REQUIRED_BODY }, + expected: 'legacy' + }, + // --- Typed-recognizer allowlist: text never upgrades, codes + structured data decide. + { + name: 'recognizer: -32601 whose message merely CONTAINS "Unsupported protocol version" is not modern evidence → legacy', + outcome: { kind: 'rpc-error', code: -32_601, message: `Unsupported protocol version: ${MODERN}` }, + expected: 'legacy' + }, + { + name: 'recognizer: -32004 with a structured supported list naming a mutual modern version → corrective continuation', + outcome: { + kind: 'rpc-error', + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: [MODERN, LATEST_PROTOCOL_VERSION], requested: '2027-01-01' } + }, + expected: 'corrective' + }, + { + name: 'recognizer: -32004 without a parsable data.supported list is not actionable modern evidence → legacy', + outcome: { kind: 'rpc-error', code: -32_004, message: 'Unsupported protocol version' }, + expected: 'legacy' + }, + { + name: 'recognizer: -32004 with a legacy-only supported list is a definitive legacy signal → legacy', + outcome: { + kind: 'rpc-error', + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: [LATEST_PROTOCOL_VERSION], requested: MODERN } + }, + expected: 'legacy' + }, + { + name: 'recognizer: a 200 result that merely mentions supportedVersions in a text field is not a DiscoverResult → legacy', + outcome: { kind: 'result', result: { content: [{ type: 'text', text: `supportedVersions: ["${MODERN}"]` }] } }, + expected: 'legacy' + }, + // --- Q12 transport-aware timeout rows (stdio falls back, HTTP stays a typed error). + { + name: 'timeout on stdio → legacy fallback (the stdio backward-compatibility rule)', + outcome: { kind: 'timeout', timeoutMs: 500 }, + expected: 'legacy' + }, + { + name: 'timeout on HTTP → typed connect error, never an era verdict', + outcome: { kind: 'timeout', timeoutMs: 500 }, + context: { transportKind: 'http' }, + expected: 'error' + }, + // --- -32601 from a deployed legacy server (the common pre-initialize answer). + { + name: 'wire-real: -32601 method-not-found → legacy fallback', + outcome: { kind: 'rpc-error', code: -32_601, message: 'Method not found' }, + expected: 'legacy' + } +]; + +describe('T9/T11 merged probe fixture corpus (probe classifier)', () => { + for (const row of CORPUS) { + it(row.name, () => { + const verdict = classifyProbeOutcome(row.outcome, { ...baseContext, ...row.context }); + expect(verdict.kind).toBe(row.expected); + }); + } + + it('a DiscoverResult with a mutual version is the only result shape that yields a modern verdict', () => { + const verdict = classifyProbeOutcome( + { + kind: 'result', + result: { supportedVersions: [MODERN], capabilities: {}, serverInfo: { name: 's', version: '1' } } + }, + baseContext + ); + expect(verdict.kind).toBe('modern'); + if (verdict.kind === 'modern') { + expect(verdict.version).toBe(MODERN); + } + }); +}); + +describe('T9 edge 5: probe wire shape (string probe id on the shared pipe)', () => { + it('probes with server/discover before any real request, using a string request id and the protocol-version envelope key', async () => { + const written: JSONRPCMessage[] = []; + // A scripted silent-legacy transport: records what the client writes and + // never answers, so only the probe (and, after its timeout, the + // initialize fallback) ever reaches the wire. + const transport: Transport = { + async start() {}, + async close() {}, + async send(message) { + written.push(message); + } + }; + + const client = new Client( + { name: 'probe-shape-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 50 } } } + ); + // The silent transport also never answers initialize; the connect + // attempt eventually fails — the probe wire shape is what this pin is + // about. + await client.connect(transport, { timeout: 200 }).catch(() => {}); + + expect(written.length).toBeGreaterThan(0); + const probe = written[0] as { id?: unknown; method?: string; params?: { _meta?: Record } }; + expect(probe.method).toBe('server/discover'); + // String probe id: the probe runs above the Protocol layer on the same + // shared pipe, so it must never collide with the numeric ids Protocol + // assigns to real requests. + expect(typeof probe.id).toBe('string'); + expect(probe.params?._meta?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN); + // Never probe with the first real request: nothing other than the probe + // and the legacy initialize fallback is written during connect. + for (const message of written) { + const method = (message as { method?: string }).method; + expect(['server/discover', 'initialize', 'notifications/initialized']).toContain(method); + } + }); +}); diff --git a/packages/client/test/client/stdioEnvPins.test.ts b/packages/client/test/client/stdioEnvPins.test.ts new file mode 100644 index 0000000000..35d6d8747d --- /dev/null +++ b/packages/client/test/client/stdioEnvPins.test.ts @@ -0,0 +1,69 @@ +/** + * Behavior-surface pins: the stdio environment-inheritance safelist. + * + * getDefaultEnvironment() decides which parent environment variables every + * spawned stdio server inherits. Widening the safelist leaks more of the + * parent environment into child processes, so both the list itself and the + * filtering behavior are pinned. A failing pin here means the change is + * deliberate: update the pin in the same change, together with a changeset + * and a migration-doc entry. + * + * See docs/behavior-surface-pins.md for the maintenance protocol. + */ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { DEFAULT_INHERITED_ENV_VARS, getDefaultEnvironment } from '../../src/client/stdio.js'; + +// Frozen copy of the documented safelist. The expectation side is a literal, +// not derived from src, so any edit to DEFAULT_INHERITED_ENV_VARS goes red +// here regardless of which variables happen to be set in the runner's +// environment. (The behavioral test below cannot catch a widened safelist on +// its own: getDefaultEnvironment skips unset keys, and sensitive variables +// are exactly the ones typically unset in CI.) +const SAFELIST = + process.platform === 'win32' + ? [ + 'APPDATA', + 'HOMEDRIVE', + 'HOMEPATH', + 'LOCALAPPDATA', + 'PATH', + 'PROCESSOR_ARCHITECTURE', + 'SYSTEMDRIVE', + 'SYSTEMROOT', + 'TEMP', + 'USERNAME', + 'USERPROFILE', + 'PROGRAMFILES' + ] + : ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER']; + +describe('stdio environment safelist', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test('DEFAULT_INHERITED_ENV_VARS matches the frozen safelist exactly', () => { + expect([...DEFAULT_INHERITED_ENV_VARS].sort()).toEqual([...SAFELIST].sort()); + }); + + test('getDefaultEnvironment inherits exactly the safelist keys that are set', () => { + for (const key of SAFELIST) { + vi.stubEnv(key, `safe-${key}`); + } + vi.stubEnv('STDIO_PIN_SECRET', 'must-not-be-inherited'); + + const env = getDefaultEnvironment(); + + expect(Object.keys(env).sort()).toEqual([...SAFELIST].sort()); + for (const key of SAFELIST) { + expect(env[key]).toBe(`safe-${key}`); + } + }); + + test('skips values that look like exported shell functions', () => { + vi.stubEnv('PATH', '() { echo pwned; }'); + const env = getDefaultEnvironment(); + expect(env.PATH).toBeUndefined(); + }); +}); diff --git a/packages/client/test/client/versionNegotiation.test.ts b/packages/client/test/client/versionNegotiation.test.ts new file mode 100644 index 0000000000..a358ca0c30 --- /dev/null +++ b/packages/client/test/client/versionNegotiation.test.ts @@ -0,0 +1,670 @@ +/** + * Connect-time version negotiation: option surface (Q5/Q12), probe mechanics + * (T9), corrective continuation (T2/A6), typed connect errors, fallback + * byte-equivalence at the message level, era scope discipline, and the + * probe-window guard. + * + * Wire-real HTTP first-contact shapes (the -32000 literal and the session- + * required 400) are exercised against real server transports in + * test/integration/test/client/versionNegotiation.test.ts. + */ +import type { JSONRPCMessage, JSONRPCRequest, Transport } from '@modelcontextprotocol/core'; +import { + isJSONRPCRequest, + PROTOCOL_VERSION_META_KEY, + SdkError, + SdkErrorCode, + UnsupportedProtocolVersionError +} from '@modelcontextprotocol/core'; +import { describe, expect, test } from 'vitest'; + +import { Client } from '../../src/client/client.js'; +import type { StreamableHTTPClientTransportOptions } from '../../src/client/streamableHttp.js'; +import type { StdioServerParameters } from '../../src/client/stdio.js'; +import { resolveVersionNegotiation } from '../../src/client/versionNegotiation.js'; + +const MODERN = '2026-07-28'; + +/* ------------------------------------------------------------------------- * + * Q5: option home — dissolved transport/stdio negotiation surfaces stay gone. + * ------------------------------------------------------------------------- */ + +describe('option surface (Q5/Q12)', () => { + test('no Transport.negotiation, no transport/stdio negotiation or probeTimeoutMs options (dissolved surfaces)', () => { + type NotAKeyOf = K extends keyof T ? false : true; + const transportHasNoNegotiation: NotAKeyOf = true; + const httpOptionsHaveNoNegotiation: NotAKeyOf = true; + const stdioHasNoNegotiation: NotAKeyOf = true; + const stdioHasNoProbeTimeout: NotAKeyOf = true; + expect(transportHasNoNegotiation).toBe(true); + expect(httpOptionsHaveNoNegotiation).toBe(true); + expect(stdioHasNoNegotiation).toBe(true); + expect(stdioHasNoProbeTimeout).toBe(true); + }); + + test('absent versionNegotiation resolves to the legacy arm (today’s default; the deferred default ruling is a one-line flip)', () => { + expect(resolveVersionNegotiation(undefined, undefined)).toEqual({ kind: 'legacy' }); + expect(resolveVersionNegotiation({}, undefined)).toEqual({ kind: 'legacy' }); + expect(resolveVersionNegotiation({ mode: 'legacy' }, undefined)).toEqual({ kind: 'legacy' }); + }); + + test('auto resolves default-agnostically: explicit mode never consults the default', () => { + const auto = resolveVersionNegotiation({ mode: 'auto' }, undefined); + expect(auto).toMatchObject({ kind: 'auto', modernVersions: [MODERN], fallbackAvailable: true }); + }); + + test('a consumer supportedProtocolVersions list drives the offer and the fallback availability', () => { + const modernOnly = resolveVersionNegotiation({ mode: 'auto' }, [MODERN]); + expect(modernOnly).toMatchObject({ kind: 'auto', modernVersions: [MODERN], fallbackAvailable: false }); + + const mixed = resolveVersionNegotiation({ mode: 'auto' }, ['2027-01-01', MODERN, '2025-11-25']); + expect(mixed).toMatchObject({ kind: 'auto', modernVersions: ['2027-01-01', MODERN], fallbackAvailable: true }); + + const legacyOnly = resolveVersionNegotiation({ mode: 'auto' }, ['2025-11-25']); + expect(legacyOnly).toMatchObject({ kind: 'auto', modernVersions: [MODERN], fallbackAvailable: true }); + }); + + test('pin requires a modern revision', () => { + expect(resolveVersionNegotiation({ mode: { pin: MODERN } }, undefined)).toMatchObject({ kind: 'pin', version: MODERN }); + expect(() => resolveVersionNegotiation({ mode: { pin: '2025-11-25' } }, undefined)).toThrow(TypeError); + }); +}); + +/* ------------------------------------------------------------------------- * + * Scripted transport for probe mechanics. + * ------------------------------------------------------------------------- */ + +type Script = (message: JSONRPCMessage, transport: ScriptedTransport) => void; + +class ScriptedTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + sessionId?: string; + + startCalls = 0; + sent: JSONRPCMessage[] = []; + setProtocolVersionCalls: string[] = []; + + constructor(private readonly script: Script) {} + + async start(): Promise { + this.startCalls++; + if (this.startCalls > 1) { + throw new Error('ScriptedTransport already started! (double-start)'); + } + } + + async send(message: JSONRPCMessage): Promise { + this.sent.push(message); + const deliver = () => this.script(message, this); + queueMicrotask(deliver); + } + + async close(): Promise { + this.onclose?.(); + } + + setProtocolVersion(version: string): void { + this.setProtocolVersionCalls.push(version); + } + + reply(message: JSONRPCMessage): void { + this.onmessage?.(message); + } +} + +const discoverResult = (supportedVersions: string[]) => ({ + supportedVersions, + capabilities: {}, + serverInfo: { name: 'scripted-modern-server', version: '1.0.0' } +}); + +/** A scripted dual-era server: answers server/discover with a DiscoverResult and initialize like a 2025 server. */ +function modernServerScript(supportedVersions: string[] = [MODERN]): Script { + return (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + t.reply({ jsonrpc: '2.0', id: message.id, result: discoverResult(supportedVersions) }); + } + }; +} + +/** A scripted 2025 server: -32601 for unknown methods, a plain initialize result otherwise. */ +const legacyServerScript: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'initialize') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: '2025-11-25', + capabilities: {}, + serverInfo: { name: 'scripted-legacy-server', version: '1.0.0' } + } + }); + } else { + t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } }); + } +}; + +const requests = (sent: JSONRPCMessage[]): JSONRPCRequest[] => sent.filter(isJSONRPCRequest); + +/* ------------------------------------------------------------------------- * + * Probe mechanics (T9) + modern resolution. + * ------------------------------------------------------------------------- */ + +describe('auto mode against a modern server', () => { + test('probe-first with a string id, no initialize, setProtocolVersion exactly once after era resolution', async () => { + const transport = new ScriptedTransport(modernServerScript()); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + await client.connect(transport); + + const sent = requests(transport.sent); + expect(sent).toHaveLength(1); + const probe = sent[0]!; + // T9: never probe with the first real request; string probe id (no + // collision with Protocol's numeric ids on shared pipes). + expect(probe.method).toBe('server/discover'); + expect(typeof probe.id).toBe('string'); + expect(String(probe.id)).toMatch(/^server-discover-probe-/); + // The probe carries the preferred version in its own _meta envelope. + const meta = (probe.params as { _meta?: Record })._meta; + expect(meta?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN); + + // No initialize, no notifications/initialized on the modern era. + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + expect(transport.sent.some(m => 'method' in m && m.method === 'notifications/initialized')).toBe(false); + + // The transport version slot was never mutated during negotiation; it is + // stamped exactly once, after the era resolved modern. + expect(transport.setProtocolVersionCalls).toEqual([MODERN]); + + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + expect(client.getServerVersion()?.name).toBe('scripted-modern-server'); + + await client.close(); + }); + + test('the probe window hands the started transport to Protocol.connect without a double start', async () => { + const transport = new ScriptedTransport(modernServerScript()); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(transport); + // ScriptedTransport.start throws on a second call — reaching here proves + // the handover absorbed Protocol.connect's unconditional start() exactly once. + expect(transport.startCalls).toBe(1); + await client.close(); + }); +}); + +/* ------------------------------------------------------------------------- * + * Fallback: byte-equivalence at the message level + zero version-slot writes. + * ------------------------------------------------------------------------- */ + +describe('auto mode against a legacy server (fallback)', () => { + test('falls back to initialize on the SAME connection; post-probe traffic is identical to a plain legacy connect', async () => { + const autoTransport = new ScriptedTransport(legacyServerScript); + const autoClient = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + await autoClient.connect(autoTransport); + + const plainTransport = new ScriptedTransport(legacyServerScript); + const plainClient = new Client({ name: 'c', version: '0' }); + await plainClient.connect(plainTransport); + + // Diff-asserted fallback hygiene: drop the probe, then the auto client's + // entire outbound sequence must be byte-identical to the plain legacy + // client's (same initialize id 0, same body incl. protocolVersion). + const autoSentAfterProbe = autoTransport.sent.slice(1); + expect(JSON.stringify(autoSentAfterProbe)).toBe(JSON.stringify(plainTransport.sent)); + + // Same setProtocolVersion behavior as the plain path (once, with the + // initialize-negotiated version) — nothing was set or cleared around the probe. + expect(autoTransport.setProtocolVersionCalls).toEqual(plainTransport.setProtocolVersionCalls); + + expect(autoClient.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + expect(plainClient.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + + await autoClient.close(); + await plainClient.close(); + }); + + test('option-parameterized oracle: a custom supportedProtocolVersions list flows into the fallback initialize body', async () => { + const versions = ['2025-06-18', '2025-03-26']; + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'initialize') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + result: { protocolVersion: '2025-06-18', capabilities: {}, serverInfo: { name: 's', version: '1' } } + }); + } else { + t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } }); + } + }; + + const autoTransport = new ScriptedTransport(script); + const autoClient = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: versions } + ); + await autoClient.connect(autoTransport); + + const plainTransport = new ScriptedTransport(script); + const plainClient = new Client({ name: 'c', version: '0' }, { supportedProtocolVersions: versions }); + await plainClient.connect(plainTransport); + + expect(JSON.stringify(autoTransport.sent.slice(1))).toBe(JSON.stringify(plainTransport.sent)); + const init = requests(autoTransport.sent)[1]!; + expect((init.params as { protocolVersion?: string }).protocolVersion).toBe('2025-06-18'); + + await autoClient.close(); + await plainClient.close(); + }); + + test('a dual-era supportedProtocolVersions list never leaks a 2026 version into the fallback initialize', async () => { + const transport = new ScriptedTransport(legacyServerScript); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: [MODERN, '2025-11-25'] } + ); + await client.connect(transport); + + // The fallback initialize offers the first LEGACY version of the list, + // never the 2026-era entry. + const init = requests(transport.sent).find(r => r.method === 'initialize')!; + expect((init.params as { protocolVersion?: string }).protocolVersion).toBe('2025-11-25'); + expect(JSON.stringify(transport.sent.slice(1))).not.toContain(MODERN); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + + await client.close(); + }); + + test('a non-conforming server that echoes a 2026 revision from initialize is rejected by the accept check', async () => { + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'initialize') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + result: { protocolVersion: MODERN, capabilities: {}, serverInfo: { name: 's', version: '1' } } + }); + } else { + t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } }); + } + }; + + const transport = new ScriptedTransport(script); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: [MODERN, '2025-11-25'] } + ); + + await expect(client.connect(transport)).rejects.toThrow(/protocol version is not supported/); + }); + + test('a modern-only client in auto mode gets a typed error instead of a fallback when the server gives no modern evidence', async () => { + const transport = new ScriptedTransport(legacyServerScript); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: [MODERN] } + ); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + // The fallback never ran: no initialize carrying any version was sent. + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); + + // Fallback against REAL servers (in-memory pair, stateful HTTP, stateless + // HTTP — both first-contact wire shapes) is covered in + // test/integration/test/client/versionNegotiation.test.ts. +}); + +/* ------------------------------------------------------------------------- * + * Probe timeout policy: transport-aware. On HTTP-class transports a timeout + * is a typed connect error (silence on a deployed server is an outage); on + * stdio it is a legacy-server signal and falls back to initialize on the same + * stream (the stdio transport's backward-compatibility rule — some legacy + * servers do not respond to unknown pre-initialize requests at all). + * ------------------------------------------------------------------------- */ + +describe('probe timeout policy (transport-aware)', () => { + const silentScript: Script = () => { + /* never replies */ + }; + + test('HTTP-class transport: timeout rejects with the standard typed timeout error and is never converted to a legacy verdict', async () => { + const transport = new ScriptedTransport(silentScript); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 50 } } }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout + ); + + // Never a legacy verdict: no initialize was attempted, before or after the timeout. + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + expect(requests(transport.sent)).toHaveLength(1); + expect(transport.setProtocolVersionCalls).toEqual([]); + }); + + /** A stdio-shaped transport: structurally recognizable by its stderr/pid accessors. */ + class StdioShapedTransport extends ScriptedTransport { + get stderr(): null { + return null; + } + get pid(): number { + return 4242; + } + } + + test('stdio-class transport: a server that never answers the probe is a legacy server — initialize fallback on the same stream', async () => { + // A silent legacy stdio server: ignores the unknown server/discover + // request entirely, but answers initialize like any 2025 server. + const silentLegacyScript: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'initialize') { + legacyServerScript(message, t); + } + // Anything else (the probe) is ignored — no reply at all. + }; + + const transport = new StdioShapedTransport(silentLegacyScript); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 30 } } }); + + await client.connect(transport); + + // The timeout resolved to the legacy verdict and the initialize fallback + // ran on the SAME transport. + const sent = requests(transport.sent); + expect(sent.filter(r => r.method === 'server/discover')).toHaveLength(1); + expect(sent.some(r => r.method === 'initialize')).toBe(true); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + + await client.close(); + }); + + test('stdio-class transport: pin mode still fails loudly on a silent server (no fallback)', async () => { + const transport = new StdioShapedTransport(() => { + /* never replies */ + }); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN }, probe: { timeoutMs: 30 } } }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); +}); + +/* ------------------------------------------------------------------------- * + * -32004 corrective continuation — exactly once; loop guard on second + * rejection. + * ------------------------------------------------------------------------- */ + +describe('-32004 corrective continuation', () => { + test('select-and-continue runs exactly once, even when the mutual version equals the just-rejected one', async () => { + let discoverCalls = 0; + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + discoverCalls++; + if (discoverCalls === 1) { + // Buggy-but-modern server: rejects the version it itself lists. + t.reply({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: [MODERN], requested: MODERN } + } + }); + } else { + t.reply({ jsonrpc: '2.0', id: message.id, result: discoverResult([MODERN]) }); + } + } + }; + + const transport = new ScriptedTransport(script); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(transport); + + // The corrective continuation is spec-mandated: the second probe still happened. + expect(discoverCalls).toBe(2); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + // MUST NOT fall back at any point. + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + + await client.close(); + }); + + test('the loop guard arms on the second rejection: typed error, never an infinite continuation', async () => { + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + t.reply({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32_004, message: 'Unsupported protocol version', data: { supported: [MODERN], requested: MODERN } } + }); + }; + + const transport = new ScriptedTransport(script); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + await expect(client.connect(transport)).rejects.toBeInstanceOf(UnsupportedProtocolVersionError); + expect(requests(transport.sent)).toHaveLength(2); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); + + test('-32004 with a disjoint-but-modern list: typed error, never initialize', async () => { + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + t.reply({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32_004, message: 'Unsupported protocol version', data: { supported: ['2027-12-31'] } } + }); + }; + + const transport = new ScriptedTransport(script); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + await expect(client.connect(transport)).rejects.toBeInstanceOf(UnsupportedProtocolVersionError); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); + + test('-32004 with a legacy-only list: definitive legacy signal, initialize on the same connection', async () => { + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32_004, message: 'Unsupported protocol version', data: { supported: ['2025-11-25'] } } + }); + } else { + legacyServerScript(message, t); + } + }; + + const transport = new ScriptedTransport(script); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(transport); + + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + await client.close(); + }); + + test('modern-only client + legacy-only -32004 list: typed error carrying data.supported', async () => { + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + t.reply({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32_004, message: 'Unsupported protocol version', data: { supported: ['2025-11-25'] } } + }); + }; + + const transport = new ScriptedTransport(script); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: [MODERN] } + ); + + const rejection = await client.connect(transport).then( + () => undefined, + error => error as UnsupportedProtocolVersionError + ); + expect(rejection).toBeInstanceOf(UnsupportedProtocolVersionError); + expect(rejection!.supported).toEqual(['2025-11-25']); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); +}); + +/* ------------------------------------------------------------------------- * + * Pin mode: no fallback, loud failure. + * ------------------------------------------------------------------------- */ + +describe('pin mode', () => { + test('modern era at the pinned version when the server offers it', async () => { + const transport = new ScriptedTransport(modernServerScript([MODERN, '2027-01-01'])); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(transport); + + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + await client.close(); + }); + + test('a legacy server fails loudly — no initialize fallback', async () => { + const transport = new ScriptedTransport(legacyServerScript); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); + + test('a modern server without the pinned version fails with typed data — never initialize', async () => { + const transport = new ScriptedTransport(modernServerScript(['2027-12-31'])); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + + const rejection = await client.connect(transport).then( + () => undefined, + error => error as UnsupportedProtocolVersionError + ); + expect(rejection).toBeInstanceOf(UnsupportedProtocolVersionError); + expect(rejection!.supported).toEqual(['2027-12-31']); + expect(rejection!.requested).toBe(MODERN); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); + + test('a failed negotiation leaves the transport start() untouched (no armed pass-through)', async () => { + const transport = new ScriptedTransport(legacyServerScript); + const originalStart = transport.start; + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + + // The probe window's one-shot start() pass-through must not stay armed + // on a transport the caller still owns after a failed connect. + expect(transport.start).toBe(originalStart); + expect(transport.onmessage).toBeUndefined(); + }); +}); + +/* ------------------------------------------------------------------------- * + * Probe-window guard: pre-init server→client traffic mid-probe is dropped + * with zero bytes. + * ------------------------------------------------------------------------- */ + +describe('probe-window guard', () => { + test('a 2025-legal pre-init server→client request arriving mid-probe is dropped with zero bytes', async () => { + const script: Script = (message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + // The server pushes a ping BEFORE answering the probe (legal on a + // 2025 stdio pipe). It must be dropped — no response bytes. + t.reply({ jsonrpc: '2.0', id: 999, method: 'ping' }); + t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } }); + } else { + legacyServerScript(message, t); + } + }; + + const transport = new ScriptedTransport(script); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(transport); + + // Zero bytes for the dropped request: nothing in the sent log answers id 999. + const repliesTo999 = transport.sent.filter(m => 'id' in m && m.id === 999); + expect(repliesTo999).toEqual([]); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + await client.close(); + }); +}); + +/* ------------------------------------------------------------------------- * + * Scope discipline: era is connection state — re-negotiated on every fresh + * connect, never silently demoted on the current connection. + * ------------------------------------------------------------------------- */ + +describe('era scope discipline', () => { + test('every fresh auto connect re-runs negotiation: no verdict survives a reconnect', async () => { + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + // First connect: probe, then fallback. + const first = new ScriptedTransport(legacyServerScript); + await client.connect(first); + expect(requests(first.sent)[0]!.method).toBe('server/discover'); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + await client.close(); + + // Second (fresh) connect: the negotiated protocol version is connection + // state and is cleared at fresh connect — the probe runs again instead + // of replaying the previous connection's verdict. + const second = new ScriptedTransport(legacyServerScript); + await client.connect(second); + expect(requests(second.sent)[0]!.method).toBe('server/discover'); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + await client.close(); + }); + + test('an established modern era is never silently demoted: later failures surface, only the NEXT connect re-negotiates', async () => { + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + const transport = new ScriptedTransport(modernServerScript()); + await client.connect(transport); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + // A later transport failure does not demote the current connection's era + // and triggers no initialize. + transport.onerror?.(new Error('boom')); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + await client.close(); + + // The next connect re-runs negotiation (the discover exchange doubles as + // the capability fetch). + const next = new ScriptedTransport(modernServerScript()); + await client.connect(next); + expect(requests(next.sent)[0]!.method).toBe('server/discover'); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + await client.close(); + }); + + test('no era state exists before the first connect, and none is persisted anywhere', () => { + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + expect(client.getNegotiatedProtocolVersion()).toBeUndefined(); + // No cachedEra option surface (deferred-additive). + type NotAKeyOf = K extends keyof T ? false : true; + const noCachedEra: NotAKeyOf[1]>, 'cachedEra'> = true; + expect(noCachedEra).toBe(true); + }); +}); diff --git a/packages/codemod/src/generated/specSchemaMap.ts b/packages/codemod/src/generated/specSchemaMap.ts index 77f3d3dfc8..99d8f84dfb 100644 --- a/packages/codemod/src/generated/specSchemaMap.ts +++ b/packages/codemod/src/generated/specSchemaMap.ts @@ -8,8 +8,6 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'CallToolRequestParamsSchema', 'CallToolRequestSchema', 'CallToolResultSchema', - 'CancelTaskRequestSchema', - 'CancelTaskResultSchema', 'CancelledNotificationParamsSchema', 'CancelledNotificationSchema', 'ClientCapabilitiesSchema', @@ -25,7 +23,6 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'CreateMessageRequestSchema', 'CreateMessageResultSchema', 'CreateMessageResultWithToolsSchema', - 'CreateTaskResultSchema', 'CursorSchema', 'DiscoverRequestSchema', 'DiscoverResultSchema', @@ -42,10 +39,6 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'GetPromptRequestParamsSchema', 'GetPromptRequestSchema', 'GetPromptResultSchema', - 'GetTaskPayloadRequestSchema', - 'GetTaskPayloadResultSchema', - 'GetTaskRequestSchema', - 'GetTaskResultSchema', 'IconSchema', 'IconsSchema', 'ImageContentSchema', @@ -72,8 +65,6 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'ListResourcesResultSchema', 'ListRootsRequestSchema', 'ListRootsResultSchema', - 'ListTasksRequestSchema', - 'ListTasksResultSchema', 'ListToolsRequestSchema', 'ListToolsResultSchema', 'LoggingLevelSchema', @@ -114,7 +105,6 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'ReadResourceResultSchema', 'RelatedTaskMetadataSchema', 'RequestIdSchema', - 'RequestMetaEnvelopeSchema', 'RequestMetaSchema', 'RequestSchema', 'ResourceContentsSchema', @@ -144,12 +134,7 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'SubscribeRequestParamsSchema', 'SubscribeRequestSchema', 'TaskAugmentedRequestParamsSchema', - 'TaskCreationParamsSchema', 'TaskMetadataSchema', - 'TaskSchema', - 'TaskStatusNotificationParamsSchema', - 'TaskStatusNotificationSchema', - 'TaskStatusSchema', 'TextContentSchema', 'TextResourceContentsSchema', 'TitledMultiSelectEnumSchemaSchema', diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts index 9efc2d5839..706c1e6e66 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts @@ -121,7 +121,7 @@ export const mcpServerApiTransform: Transform = { } } - changesCount += migrateConstructorTaskOptions(sourceFile, diagnostics); + flagRemovedTaskOptions(sourceFile, diagnostics); return { changesCount, diagnostics }; } @@ -414,11 +414,17 @@ function migrateResourceCall(call: CallExpression, _sourceFile: SourceFile): boo const TASK_OPTIONS = ['taskStore', 'taskMessageQueue'] as const; -function migrateConstructorTaskOptions(sourceFile: SourceFile, diagnostics: Diagnostic[]): number { +/** + * Flag v1 task runtime options on the McpServer constructor as removed. + * + * The experimental tasks runtime was removed in v2 (SEP-2663) with no replacement, so + * these options cannot be migrated automatically. Emit an action-required diagnostic + * matching the importMap removal entry for `experimental/tasks`; the source is left + * untouched. + */ +function flagRemovedTaskOptions(sourceFile: SourceFile, diagnostics: Diagnostic[]): void { const localName = resolveLocalImportName(sourceFile, 'McpServer'); - if (!localName) return 0; - - let changes = 0; + if (!localName) return; for (const node of sourceFile.getDescendantsOfKind(SyntaxKind.NewExpression)) { if (node.wasForgotten()) continue; @@ -431,110 +437,15 @@ function migrateConstructorTaskOptions(sourceFile: SourceFile, diagnostics: Diag const optionsArg = args[1]!; if (!Node.isObjectLiteralExpression(optionsArg)) continue; - // Check if any task options are present at the top level - const propsToMove: string[] = []; for (const propName of TASK_OPTIONS) { - if (optionsArg.getProperty(propName)) { - propsToMove.push(propName); - } - } - if (propsToMove.length === 0) continue; - - // Find the tasks object's position within the options text using AST, - // then do all mutations via a single text replacement to avoid node invalidation. - const capabilitiesProp = optionsArg.getProperty('capabilities'); - let tasksObjStart = -1; - let tasksObjEnd = -1; - const optionsStart = optionsArg.getStart(); - if (capabilitiesProp && Node.isPropertyAssignment(capabilitiesProp)) { - const capInit = capabilitiesProp.getInitializer(); - if (capInit && Node.isObjectLiteralExpression(capInit)) { - const tasksProp = capInit.getProperty('tasks'); - if (tasksProp && Node.isPropertyAssignment(tasksProp)) { - const tasksInit = tasksProp.getInitializer(); - if (tasksInit && Node.isObjectLiteralExpression(tasksInit)) { - tasksObjStart = tasksInit.getStart() - optionsStart; - tasksObjEnd = tasksInit.getEnd() - optionsStart; - } - } - } - } - - if (tasksObjStart === -1) { - for (const propName of propsToMove) { - diagnostics.push( - actionRequired( - sourceFile.getFilePath(), - node, - `Move '${propName}' from McpServer options into capabilities.tasks — v2 expects task runtime options inside the tasks capability.` - ) - ); - } - continue; - } - - // Single text replacement: remove top-level props and insert into tasks object. - // Use AST nodes (already located via getProperty) to get brace-balanced text and - // exact positions, avoiding regex truncation on values containing commas/braces. - // Collect all properties first, then process in reverse position order so each - // removal doesn't invalidate the positions of subsequent removals. - let optionsText = optionsArg.getText(); - const argStart = optionsArg.getStart(); - const propsWithPositions: { text: string; start: number; end: number }[] = []; - for (const propName of propsToMove) { - const prop = optionsArg.getProperty(propName); - if (!prop) continue; - propsWithPositions.push({ - text: prop.getText(), - start: prop.getStart() - argStart, - end: prop.getEnd() - argStart - }); + if (!optionsArg.getProperty(propName)) continue; + diagnostics.push( + actionRequired( + sourceFile.getFilePath(), + node, + `Remove '${propName}' from McpServer options — experimental tasks removed in v2 (SEP-2663 — tasks moved to the Extensions Track). No v2 equivalent.` + ) + ); } - const propTexts = propsWithPositions.map(p => p.text); - - // Remove in reverse position order so earlier positions remain valid - const sortedProps = propsWithPositions.toSorted((a, b) => b.start - a.start); - for (const { start, end } of sortedProps) { - let remStart = start; - let remEnd = end; - // Consume trailing comma and whitespace - const afterProp = optionsText.slice(remEnd); - const trailingMatch = afterProp.match(/^\s*,?\s*/); - if (trailingMatch) { - remEnd += trailingMatch[0].length; - } - // Consume leading whitespace/newline - const beforeProp = optionsText.slice(0, remStart); - const leadingMatch = beforeProp.match(/[\n\r]?\s*$/); - if (leadingMatch) { - remStart -= leadingMatch[0].length; - } - optionsText = optionsText.slice(0, remStart) + optionsText.slice(remEnd); - // Adjust tasks position if removal was before it - if (remStart < tasksObjStart) { - const shift = remEnd - remStart; - tasksObjStart -= shift; - tasksObjEnd -= shift; - } - } - - if (propTexts.length === 0) continue; - - // Insert into the tasks object (just before its closing brace) - const tasksText = optionsText.slice(tasksObjStart, tasksObjEnd); - const closingBrace = tasksText.lastIndexOf('}'); - const before = tasksText.slice(0, closingBrace).trimEnd(); - const sep = before.length > 1 ? ',\n' : '\n'; - const newTasksText = before + sep + propTexts.join(',\n') + '\n' + tasksText.slice(closingBrace); - optionsText = optionsText.slice(0, tasksObjStart) + newTasksText + optionsText.slice(tasksObjEnd); - - // Clean up double/trailing commas - optionsText = optionsText.replaceAll(/,(\s*,)/g, ','); - optionsText = optionsText.replaceAll(/,(\s*})/g, '$1'); - - optionsArg.replaceWithText(optionsText); - changes += propTexts.length; } - - return changes; } diff --git a/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts b/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts index b18a1abb3f..461cfb5da0 100644 --- a/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts @@ -307,4 +307,63 @@ describe('mcp-server-api transform', () => { expect(result).toContain('registerTool("ping", {}'); expect(result).not.toContain('z.object'); }); + + it('flags taskStore in McpServer options as removed without modifying code', () => { + const input = [ + `const server = new McpServer(`, + ` { name: 'test', version: '1.0' },`, + ` { taskStore: new InMemoryTaskStore() }`, + `);`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = mcpServerApiTransform.apply(sourceFile, ctx); + expect(sourceFile.getFullText()).toBe(MCP_IMPORT + input); + const taskDiags = result.diagnostics.filter(d => d.message.includes("'taskStore'")); + expect(taskDiags).toHaveLength(1); + expect(taskDiags[0]!.message).toContain('experimental tasks removed in v2 (SEP-2663'); + expect(taskDiags[0]!.message).toContain('No v2 equivalent'); + expect(taskDiags[0]!.insertComment).toBe(true); + }); + + it('flags each task option separately when both are present', () => { + const input = [ + `const server = new McpServer(`, + ` { name: 'test', version: '1.0' },`, + ` { taskStore: store, taskMessageQueue: queue }`, + `);`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = mcpServerApiTransform.apply(sourceFile, ctx); + expect(sourceFile.getFullText()).toBe(MCP_IMPORT + input); + expect(result.diagnostics.some(d => d.message.includes("'taskStore'"))).toBe(true); + expect(result.diagnostics.some(d => d.message.includes("'taskMessageQueue'"))).toBe(true); + }); + + it('does not move task options into capabilities.tasks even when present', () => { + const input = [ + `const server = new McpServer(`, + ` { name: 'test', version: '1.0' },`, + ` { taskStore: store, capabilities: { tasks: {} } }`, + `);`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = mcpServerApiTransform.apply(sourceFile, ctx); + expect(sourceFile.getFullText()).toBe(MCP_IMPORT + input); + expect(sourceFile.getFullText()).toContain('taskStore: store'); + expect(result.diagnostics.some(d => d.message.includes("'taskStore'"))).toBe(true); + }); + + it('emits no task diagnostics for McpServer options without task options', () => { + const input = [`const server = new McpServer({ name: 'test', version: '1.0' }, { instructions: 'hi' });`, ''].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = mcpServerApiTransform.apply(sourceFile, ctx); + expect(result.diagnostics).toHaveLength(0); + }); }); diff --git a/packages/core/src/errors/sdkErrors.ts b/packages/core/src/errors/sdkErrors.ts index af432c6389..eec7596cc5 100644 --- a/packages/core/src/errors/sdkErrors.ts +++ b/packages/core/src/errors/sdkErrors.ts @@ -28,6 +28,29 @@ export enum SdkErrorCode { SendFailed = 'SEND_FAILED', /** Response result failed local schema validation */ InvalidResult = 'INVALID_RESULT', + /** + * The response carried a `resultType` discriminator (protocol revision + * 2026-07-28) naming a result kind this client cannot consume yet, e.g. + * `input_required`. The kind is carried in `data.resultType`. + */ + UnsupportedResultType = 'UNSUPPORTED_RESULT_TYPE', + /** + * The spec method being sent does not exist on the negotiated protocol + * version's wire era (e.g. `tasks/get` toward a 2026-07-28 peer, or + * `server/discover` toward a 2025-era peer). Raised locally, before + * anything reaches the transport. The method and era are carried in + * `data.method` / `data.era`. + */ + MethodNotSupportedByProtocolVersion = 'METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION', + /** + * Protocol-era negotiation at connect time failed without producing either a + * usable modern (2026-07-28+) era or a definitive legacy fallback signal — + * e.g. the negotiation mode forbids falling back (`pin`), or the probe hit a + * network failure (a typed connect error, never an era verdict). + * + * Negotiation-phase only: this code is never used once an era is established. + */ + EraNegotiationFailed = 'ERA_NEGOTIATION_FAILED', // Transport errors ClientHttpNotImplemented = 'CLIENT_HTTP_NOT_IMPLEMENTED', 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 a704267ee3..b74b370335 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,17 +2,29 @@ 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'; 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'; export * from './shared/uriTemplate.js'; export * from './types/index.js'; export * from './util/inMemory.js'; +// Wire-codec internals: ONLY the version→codec resolver the sibling packages +// need (era state itself lives on Protocol and is written through the +// package-internal write hook exported by shared/protocol.ts). Nothing +// per-revision (schemas, registries, codec objects) is ever exported — not +// even on this internal barrel — so per-era vocabulary cannot leak toward the +// public surface. export * from './util/schema.js'; export * from './util/standardSchema.js'; export * from './util/zodCompat.js'; +export { codecForVersion } from './wire/codec.js'; // Validator providers are type-only here — import the runtime classes from the explicit // `@modelcontextprotocol/{core,client,server}/validators/{ajv,cf-worker}` subpaths to customise. 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/envelope.ts b/packages/core/src/shared/envelope.ts new file mode 100644 index 0000000000..3aba586452 --- /dev/null +++ b/packages/core/src/shared/envelope.ts @@ -0,0 +1,99 @@ +/** + * Per-request `_meta` envelope claim helpers (protocol revision 2026-07-28). + * + * Pure, value-returning helpers used by the inbound HTTP classifier + * (`classifyInboundRequest`): claim detection and envelope validation with + * self-identifying issues. The envelope schema itself stays the wire layer's + * single source of truth (`RequestMetaEnvelopeSchema`); this module only maps + * its outcomes into the shapes the validation ladder emits. + * + * Claim detection is deliberately narrow: a message claims the 2026-07-28 + * envelope mechanism if and only if the reserved protocol-version `_meta` key + * is present in `params._meta`. Other reserved keys (client info, client + * capabilities, log level), a bare `progressToken`, or unrelated keys under + * the `io.modelcontextprotocol/` prefix do NOT constitute a claim on their + * own — but once the claim key is present, a malformed envelope is a + * validation error, never a silent fall back to legacy handling. + */ +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '../types/constants.js'; +import { RequestMetaEnvelopeSchema } from '../wire/rev2026-07-28/schemas.js'; + +/** A single self-identifying problem found while validating a per-request `_meta` envelope. */ +export interface EnvelopeIssue { + /** + * The envelope key the problem is about: one of the reserved `_meta` keys, + * or a dotted path inside one (e.g. `io.modelcontextprotocol/clientInfo.name`). + */ + key: string; + /** A short description of what is wrong with that key (`missing`, or a validation message). */ + problem: string; +} + +/** The reserved `_meta` keys an envelope must carry (in reporting order). */ +const REQUIRED_ENVELOPE_KEYS: readonly string[] = [PROTOCOL_VERSION_META_KEY, CLIENT_INFO_META_KEY, CLIENT_CAPABILITIES_META_KEY]; + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** The `_meta` object of a message's params, when present. */ +export function requestMetaOf(params: unknown): Record | undefined { + if (!isPlainObject(params)) return undefined; + const meta = params['_meta']; + return isPlainObject(meta) ? meta : undefined; +} + +/** + * Whether a message's params carry the per-request envelope claim: the + * reserved protocol-version `_meta` key is present (regardless of whether the + * rest of the envelope is valid — validation is a separate, later step). + */ +export function hasEnvelopeClaim(params: unknown): boolean { + const meta = requestMetaOf(params); + return meta !== undefined && PROTOCOL_VERSION_META_KEY in meta; +} + +/** + * The protocol version named by a message's envelope claim, when the claim is + * present and carries a string value. A present claim with a non-string value + * still counts as a claim ({@linkcode hasEnvelopeClaim}); it surfaces as a + * validation issue instead of a version. + */ +export function envelopeClaimVersion(params: unknown): string | undefined { + const meta = requestMetaOf(params); + const value = meta?.[PROTOCOL_VERSION_META_KEY]; + return typeof value === 'string' ? value : undefined; +} + +/** + * Validates a request's `_meta` object as a 2026-07-28 per-request envelope + * and reports problems as self-identifying issues (which key, what problem). + * + * Returns an empty array when the envelope is valid. Missing required keys are + * reported first (as `problem: 'missing'`), then schema violations inside + * present keys, in a stable order. + */ +export function validateEnvelopeMeta(meta: Record): EnvelopeIssue[] { + const issues: EnvelopeIssue[] = []; + + for (const key of REQUIRED_ENVELOPE_KEYS) { + if (!(key in meta)) { + issues.push({ key, problem: 'missing' }); + } + } + + const parsed = RequestMetaEnvelopeSchema.safeParse(meta); + if (!parsed.success) { + for (const issue of parsed.error.issues) { + const path = issue.path.map(String); + const key = path.length > 0 ? path.join('.') : '_meta'; + // Missing required keys were already reported above in canonical order. + if (path.length === 1 && issues.some(existing => existing.key === key && existing.problem === 'missing')) { + continue; + } + issues.push({ key, problem: issue.message }); + } + } + + return issues; +} diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts new file mode 100644 index 0000000000..3dd85ad2ad --- /dev/null +++ b/packages/core/src/shared/inboundClassification.ts @@ -0,0 +1,770 @@ +/** + * Inbound HTTP request classification and the inbound validation ladder + * (protocol revision 2026-07-28). + * + * `classifyInboundRequest` is the body-primary era predicate for an HTTP + * entry that serves both protocol eras on one endpoint. It is evaluated + * exactly once, at the entry boundary, on the already-parsed request body: + * + * - `initialize` is a legacy-era request by definition (the modern era has no + * `initialize` handshake) — unless it carries a valid envelope claim naming + * a modern revision, in which case the claim wins and the request is + * classified like any other enveloped request (the modern era then answers + * it with method-not-found, exactly like every other method it does not + * define). + * - A request whose `params._meta` carries the reserved protocol-version key + * claims the per-request envelope mechanism and classifies into the era the + * named revision belongs to (a malformed envelope behind a present claim is + * a validation error, never a silent fall back to legacy handling). + * - A request without a claim is legacy-era traffic. + * - The `MCP-Protocol-Version` header is a cross-check only: it never + * upgrades or downgrades a body-derived classification, and a disagreement + * between header and body is an explicit ladder outcome. + * - Notifications carry no envelope claim of their own under the current + * spec, so for notification POSTs without a body claim the modern header is + * determinative; the `Mcp-Method` header is validated against the body when + * the message classifies modern and is never enforced on legacy traffic. + * A notification that does carry a claim is treated body-primary like a + * request, and a malformed claim is rejected the same way a request's + * malformed claim is — never silently resolved against the header. + * - `GET`/`DELETE` (and any other non-`POST` method) are body-less 2025-era + * session operations: the modern era is `POST`-only, so they are routed to + * legacy serving when it is configured and rejected otherwise. + * - Array (batch) bodies are classified element-wise: an array containing a + * modern-claiming or invalid element is rejected, an all-legacy array is + * legacy traffic unchanged, and a single-element array is still an array. + * + * The classifier returns plain values (it never throws and never touches a + * transport): a routing outcome (`legacy`/`modern`) or a ladder rejection + * carrying the JSON-RPC error to emit and the HTTP status to emit it with. + * Legacy routing outcomes deliberately carry NO `MessageClassification` — + * legacy and hand-wired traffic is never classified, which keeps its + * dispatch behavior byte-identical to today's. + * + * Error codes for the modern-path rejection cells follow the published + * conformance suite (and the spec text it asserts): + * + * - A header/body cross-check mismatch (the `MCP-Protocol-Version` header + * disagreeing with the body, or the `Mcp-Method` header disagreeing with the + * body method) is rejected with `-32001` (`HeaderMismatch`) on HTTP 400. + * - A request whose protocol-version header names a modern revision but whose + * body carries no `_meta` envelope claim — including an envelope present but + * missing the required protocol-version key — is rejected with `-32602` + * (invalid params) naming the missing key(s), on HTTP 400. + * + * Should a future spec revision or conformance release change these + * assignments, the affected cells are re-derived against that release; the + * `settled` flag on {@linkcode InboundLadderRejection} stays available to mark + * a cell provisional again while such a change is in flight. + */ +import { PROTOCOL_VERSION_META_KEY } from '../types/constants.js'; +import { ProtocolErrorCode } from '../types/enums.js'; +import { ProtocolError, UnsupportedProtocolVersionError } from '../types/errors.js'; +import { isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse } from '../types/guards.js'; +import type { MessageClassification } from '../types/types.js'; +import { envelopeClaimVersion, hasEnvelopeClaim, requestMetaOf, validateEnvelopeMeta } from './envelope.js'; +import { isModernProtocolVersion } from './protocolEras.js'; + +/* ------------------------------------------------------------------------ * + * Classifier input + * ------------------------------------------------------------------------ */ + +/** + * The transport-neutral description of an inbound HTTP request the classifier + * evaluates. The caller (the HTTP entry) reads the body exactly once and + * extracts the two protocol headers; the classifier never touches a request + * object itself. + */ +export interface InboundHttpRequest { + /** The HTTP request method, e.g. `POST`, `GET`, `DELETE`. */ + httpMethod: string; + /** The value of the `MCP-Protocol-Version` header, when present. */ + protocolVersionHeader?: string; + /** The value of the `Mcp-Method` header, when present. */ + mcpMethodHeader?: string; + /** The parsed JSON request body (`undefined` for body-less methods). */ + body?: unknown; +} + +/* ------------------------------------------------------------------------ * + * Classifier outcomes + * ------------------------------------------------------------------------ */ + +/** Why an inbound request was routed to legacy-era serving. */ +export type InboundLegacyRouteReason = + /** Non-`POST` HTTP method: a body-less 2025-era session operation. */ + | 'http-method' + /** An `initialize` request without a valid modern envelope claim — the legacy handshake by definition. */ + | 'initialize' + /** A request without a per-request envelope claim. */ + | 'no-claim' + /** A notification without a body claim or a modern protocol-version header. */ + | 'notification' + /** An all-legacy JSON-RPC batch array. */ + | 'batch' + /** A JSON-RPC response posted to the endpoint (2025-era session traffic). */ + | 'response'; + +/** + * The request is legacy-era traffic. It carries no classification on purpose: + * legacy serving receives it exactly as a hand-wired 2025 transport would. + */ +export interface InboundLegacyRoute { + kind: 'legacy'; + reason: InboundLegacyRouteReason; + /** + * The protocol version the request named, when it named one (an + * `initialize` body's `protocolVersion`, or the `MCP-Protocol-Version` + * header). Used to echo `requested` when legacy serving is not configured. + */ + requestedVersion?: string; +} + +/** The request claims the per-request envelope mechanism and is served on the modern path. */ +export interface InboundModernRoute { + kind: 'modern'; + /** Whether the classified message is a request or a notification. */ + messageKind: 'request' | 'notification'; + /** + * The classification handed to the per-request transport and validated by + * the protocol layer against the serving instance's negotiated era. + */ + classification: MessageClassification; +} + +/** The named steps of the inbound validation ladder, in evaluation order. */ +export type InboundValidationRung = + | 'http-method' + | 'jsonrpc-shape' + | 'era-classification' + | 'envelope' + | 'method-registry' + | 'request-params' + | 'client-capabilities'; + +/** A ladder rejection: the JSON-RPC error to emit and the HTTP status to emit it with. */ +export interface InboundLadderRejection { + kind: 'reject'; + /** The ladder rung that produced the rejection. */ + rung: InboundValidationRung; + /** The cell this rejection corresponds to on the ladder cell sheet (stable identifier for tests). */ + cell: string; + /** The HTTP status the rejection is emitted with. */ + httpStatus: number; + /** The JSON-RPC error code. */ + code: number; + /** The JSON-RPC error message. */ + message: string; + /** Structured error data (recognizers parse this; they never rely on class identity). */ + data?: unknown; + /** + * `false` when the exact error code for this cell is not settled upstream + * yet and the emitted code is provisional. + */ + settled: boolean; +} + +/** The outcome of classifying one inbound HTTP request. */ +export type InboundClassificationOutcome = InboundLegacyRoute | InboundModernRoute | InboundLadderRejection; + +/* ------------------------------------------------------------------------ * + * Header cross-check mismatches + * ------------------------------------------------------------------------ */ + +/** + * The error code emitted for header/body cross-check mismatches: the + * `MCP-Protocol-Version` header disagreeing with the body's envelope claim (or + * with the body's classification), and the `Mcp-Method` header disagreeing + * with the body method. + * + * `-32001` is the SEP-2243 `HeaderMismatch` code, as asserted by the published + * conformance suite for header-validation failures. It has no + * {@linkcode ProtocolErrorCode} member because it is not part of the 2025-era + * wire vocabulary; the validation ladder is its only emitter. + */ +export const HEADER_MISMATCH_ERROR_CODE = -32_001; + +/* ------------------------------------------------------------------------ * + * The validation ladder as data + * ------------------------------------------------------------------------ */ + +/** One rung of the inbound validation ladder. */ +export interface InboundValidationRungDescriptor { + rung: InboundValidationRung; + /** Evaluation order: lower runs first; an earlier rung's outcome wins over a later rung's. */ + order: number; + /** + * Where the rung is evaluated: at the HTTP entry edge 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). */ + conformance: readonly string[]; + /** Why the rung sits where it does. */ + rationale: string; +} + +/** + * The inbound validation ladder, expressed as data rather than control flow. + * + * The edge rungs are evaluated by {@linkcode classifyInboundRequest}; the + * dispatch rungs are evaluated by the protocol layer once the classified + * message is injected into a per-request server instance (the era registry + * gate, the envelope requiredness check, 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[] = [ + { + rung: 'http-method', + order: 1, + evaluatedAt: 'edge', + codes: [-32_000], + conformance: [], + rationale: + 'The modern era is POST-only; GET/DELETE are body-less 2025-era session operations and are method-routed to legacy ' + + 'serving (405 when legacy serving is not configured), before any body is read.' + }, + { + rung: 'jsonrpc-shape', + order: 2, + evaluatedAt: 'edge', + codes: [ProtocolErrorCode.InvalidRequest], + conformance: ['server-stateless'], + rationale: + 'The body must be a JSON-RPC request or notification: posted responses and batch arrays containing a modern or ' + + 'invalid element are rejected before classification (element-wise batch rule); all-legacy arrays stay legacy traffic.' + }, + { + rung: 'era-classification', + order: 3, + evaluatedAt: 'edge', + codes: [HEADER_MISMATCH_ERROR_CODE, ProtocolErrorCode.UnsupportedProtocolVersion], + conformance: ['server-stateless', 'http-header-validation', 'http-custom-header-server-validation'], + rationale: + 'Body-primary era classification with the protocol-version header as a cross-check; a header/body disagreement is rejected ' + + 'with -32001 (HeaderMismatch), and an envelope-less request on a modern-only endpoint is answered with the ' + + 'unsupported-protocol-version error naming the supported revisions.' + }, + { + rung: 'envelope', + order: 4, + evaluatedAt: 'edge', + codes: [ProtocolErrorCode.InvalidParams], + conformance: ['server-stateless'], + rationale: + 'A present envelope claim with a malformed envelope — and a missing envelope on a request whose protocol-version header ' + + 'names a modern revision — is an invalid-params rejection naming the offending or missing key(s); never a silent fall ' + + 'back to legacy handling. This is the only place an invalid-params rejection maps to HTTP 400.' + }, + { + rung: 'method-registry', + order: 5, + evaluatedAt: 'dispatch', + codes: [ProtocolErrorCode.MethodNotFound], + conformance: ['server-stateless'], + rationale: + 'Method existence outranks parameter validity: a method absent from the negotiated revision’s registry (or with no ' + + 'handler installed) answers method-not-found before params or capabilities are looked at.' + }, + { + rung: 'request-params', + order: 6, + evaluatedAt: 'dispatch', + codes: [ProtocolErrorCode.InvalidParams], + conformance: [], + rationale: 'Per-method params validation; emitted in-band by the dispatch layer (HTTP 200), never via the ladder status table.' + }, + { + rung: 'client-capabilities', + order: 7, + evaluatedAt: 'pre-dispatch', + codes: [ProtocolErrorCode.MissingRequiredClientCapability], + conformance: ['server-stateless'], + rationale: + '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.' + } +]; + +/* ------------------------------------------------------------------------ * + * HTTP status mapping for ladder-originated errors + * ------------------------------------------------------------------------ */ + +/** + * HTTP status for ladder-originated JSON-RPC error codes. + * + * Keyed on origin, not on the bare code: this table only applies to errors + * the ladder (or a pre-handler protocol gate) produced. Errors produced by + * request handlers — whatever their code — stay in-band on HTTP 200, and are + * never mapped to an HTTP status by this table; in particular `-32603` and + * domain-specific codes never become a blanket 500. + * + * `-32602` (invalid params) deliberately has NO entry: the only invalid-params + * rejection that maps to HTTP 400 is the classifier's own envelope rung + * short-circuit, which carries its HTTP status directly. A dispatch- or + * handler-produced invalid-params error is always in-band. + */ +export const LADDER_ERROR_HTTP_STATUS: Readonly> = { + [ProtocolErrorCode.ParseError]: 400, + [ProtocolErrorCode.InvalidRequest]: 400, + [ProtocolErrorCode.MethodNotFound]: 404, + [ProtocolErrorCode.UnsupportedProtocolVersion]: 400, + [ProtocolErrorCode.MissingRequiredClientCapability]: 400, + [HEADER_MISMATCH_ERROR_CODE]: 400 +}; + +/** + * The HTTP status to answer a JSON-RPC error with, keyed on the error's + * origin. `in-band` errors (anything produced by a request handler) are + * always HTTP 200 — the JSON-RPC error response is the payload, not an HTTP + * failure. `ladder` errors map through {@linkcode LADDER_ERROR_HTTP_STATUS}. + */ +export function httpStatusForErrorCode(code: number, origin: 'ladder' | 'in-band'): number { + if (origin === 'in-band') return 200; + return LADDER_ERROR_HTTP_STATUS[code] ?? 400; +} + +/* ------------------------------------------------------------------------ * + * The classifier + * ------------------------------------------------------------------------ */ + +function rejection( + rung: InboundValidationRung, + cell: string, + httpStatus: number, + error: ProtocolError, + settled: boolean +): InboundLadderRejection { + return { + kind: 'reject', + rung, + cell, + httpStatus, + code: error.code, + message: error.message, + ...(error.data !== undefined && { data: error.data }), + settled + }; +} + +function crossCheckMismatch(cell: string, header: string, body: string): InboundLadderRejection { + return rejection( + 'era-classification', + cell, + 400, + new ProtocolError(HEADER_MISMATCH_ERROR_CODE, `Bad Request: the request headers and body disagree: ${body}`, { + mismatch: { header, body } + }), + true + ); +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function classificationForClaim(claimedVersion: string | undefined): MessageClassification { + if (claimedVersion === undefined) { + return { era: 'modern' }; + } + return { era: isModernProtocolVersion(claimedVersion) ? 'modern' : 'legacy', revision: claimedVersion }; +} + +/** + * Whether a request's params carry a per-request envelope claim that is both + * well-formed and names a modern protocol revision. + * + * Used by the `initialize` precedence rule: only such a claim overrides the + * `initialize` ⇒ legacy-handshake classification — a request carrying a valid + * modern envelope is a modern request regardless of its method name, and the + * modern era then answers `initialize` exactly like any other method it does + * not define (method-not-found). A malformed claim, or one naming a pre-2026 + * revision, keeps the legacy-handshake routing unchanged. + */ +function carriesValidModernEnvelopeClaim(params: unknown): boolean { + if (!hasEnvelopeClaim(params)) { + return false; + } + const claimedVersion = envelopeClaimVersion(params); + if (claimedVersion === undefined || !isModernProtocolVersion(claimedVersion)) { + return false; + } + const meta = requestMetaOf(params); + return meta !== undefined && validateEnvelopeMeta(meta).length === 0; +} + +function classifyBatch(body: readonly unknown[]): InboundClassificationOutcome { + if (body.length === 0) { + return rejection( + 'jsonrpc-shape', + 'empty-batch', + 400, + new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Bad Request: empty JSON-RPC batch'), + true + ); + } + for (const element of body) { + const params = isPlainObject(element) ? element['params'] : undefined; + if (hasEnvelopeClaim(params)) { + // Element-wise rule: a single modern element makes the whole array + // unservable — modern requests are single-message POSTs, and the + // legacy path must never serve an envelope-claiming element. + return rejection( + 'jsonrpc-shape', + 'batch-with-modern-element', + 400, + new ProtocolError( + ProtocolErrorCode.InvalidRequest, + 'Bad Request: JSON-RPC batches may not contain requests for protocol revision 2026-07-28 or later' + ), + true + ); + } + const valid = + isJSONRPCRequest(element) || + isJSONRPCNotification(element) || + isJSONRPCResultResponse(element) || + isJSONRPCErrorResponse(element); + if (!valid) { + return rejection( + 'jsonrpc-shape', + 'batch-with-invalid-element', + 400, + new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Bad Request: JSON-RPC batch contains an invalid message'), + true + ); + } + } + // All elements are legacy-era messages: legacy serving takes the array unchanged. + return { kind: 'legacy', reason: 'batch' }; +} + +function classifyRequestBody(request: InboundHttpRequest, body: Record): InboundClassificationOutcome { + const params = body['params']; + const method = body['method'] as string; + const headerVersion = request.protocolVersionHeader; + const headerNamesModern = headerVersion !== undefined && isModernProtocolVersion(headerVersion); + + // `initialize` is the legacy handshake by definition — unless the request + // carries a valid envelope claim naming a modern revision, in which case + // the claim wins: the request is classified like any other enveloped + // request and served on the modern path, where the modern registry answers + // `initialize` as method-not-found like every other method it does not + // define. A malformed or absent claim, or a claim naming a pre-2026 + // revision, keeps the legacy-handshake classification below. + if (method === 'initialize' && !carriesValidModernEnvelopeClaim(params)) { + if (headerNamesModern) { + return crossCheckMismatch( + 'initialize-with-modern-header', + headerVersion, + 'an initialize request (legacy handshake) was sent with a modern MCP-Protocol-Version header' + ); + } + const requestedVersion = + isPlainObject(params) && typeof params['protocolVersion'] === 'string' ? params['protocolVersion'] : undefined; + return { kind: 'legacy', reason: 'initialize', ...(requestedVersion !== undefined && { requestedVersion }) }; + } + + if (hasEnvelopeClaim(params)) { + // A present claim is validated, never silently ignored: a malformed + // envelope behind the claim is an invalid-params rejection naming the + // offending key, not a fall back to legacy handling. + const meta = requestMetaOf(params); + const issues = meta === undefined ? [] : validateEnvelopeMeta(meta); + const firstIssue = issues[0]; + if (firstIssue !== undefined) { + return rejection( + 'envelope', + 'envelope-invalid', + 400, + new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Invalid _meta envelope for protocol revision 2026-07-28: ${firstIssue.key}: ${firstIssue.problem}`, + { envelope: firstIssue } + ), + true + ); + } + + const claimedVersion = envelopeClaimVersion(params); + if (headerVersion !== undefined && claimedVersion !== undefined && headerVersion !== claimedVersion) { + return crossCheckMismatch( + 'header-body-version-mismatch', + headerVersion, + `the body envelope names protocol version ${claimedVersion} but the MCP-Protocol-Version header names ${headerVersion}` + ); + } + if (request.mcpMethodHeader !== undefined && request.mcpMethodHeader !== method) { + return crossCheckMismatch( + 'method-header-mismatch', + request.mcpMethodHeader, + `the body names method ${method} but the Mcp-Method header names ${request.mcpMethodHeader}` + ); + } + return { kind: 'modern', messageKind: 'request', classification: classificationForClaim(claimedVersion) }; + } + + // No claim: legacy-era traffic — unless the protocol-version header names a + // modern revision. The modern revisions carry their request metadata in the + // per-request `_meta` envelope, so a modern-classified request without one + // is missing required params: it is rejected with invalid params naming the + // missing key(s), never silently served as legacy traffic and never + // upgraded from the header alone. + if (headerNamesModern) { + const meta = requestMetaOf(params); + const missingFromEnvelope = validateEnvelopeMeta(meta ?? {}) + .filter(issue => issue.problem === 'missing') + .map(issue => issue.key); + const missing = meta === undefined ? ['_meta'] : missingFromEnvelope.length > 0 ? missingFromEnvelope : [PROTOCOL_VERSION_META_KEY]; + return rejection( + 'envelope', + 'modern-header-without-claim', + 400, + new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Invalid params: the MCP-Protocol-Version header names protocol revision ${headerVersion}, but the request is missing ` + + `the required per-request envelope key(s): ${missing.join(', ')}`, + { envelope: { missing } } + ), + true + ); + } + return { kind: 'legacy', reason: 'no-claim', ...(headerVersion !== undefined && { requestedVersion: headerVersion }) }; +} + +function classifyNotificationBody(request: InboundHttpRequest, body: Record): InboundClassificationOutcome { + const params = body['params']; + const method = body['method'] as string; + const headerVersion = request.protocolVersionHeader; + const headerNamesModern = headerVersion !== undefined && isModernProtocolVersion(headerVersion); + + if (hasEnvelopeClaim(params)) { + // Body-primary even for notifications: a body claim wins over the + // header, and a disagreement between them is rejected rather than + // letting either signal silently pick the serving path. + const claimedVersion = envelopeClaimVersion(params); + if (claimedVersion === undefined) { + // The claim key is present but its value is malformed (not a + // string). Validated exactly like a request claim: an + // invalid-params rejection naming the offending key — never a + // silent win against (or loss to) a disagreeing header. + const meta = requestMetaOf(params); + const issues = meta === undefined ? [] : validateEnvelopeMeta(meta); + const claimIssue = issues.find(issue => issue.key === PROTOCOL_VERSION_META_KEY) ?? { + key: PROTOCOL_VERSION_META_KEY, + problem: 'expected a protocol version string' + }; + return rejection( + 'envelope', + 'notification-envelope-invalid', + 400, + new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Invalid _meta envelope for protocol revision 2026-07-28: ${claimIssue.key}: ${claimIssue.problem}`, + { envelope: claimIssue } + ), + true + ); + } + if (headerVersion !== undefined && headerVersion !== claimedVersion) { + return crossCheckMismatch( + 'notification-header-body-version-mismatch', + headerVersion, + `the notification envelope names protocol version ${claimedVersion} but the MCP-Protocol-Version header names ${headerVersion}` + ); + } + const classification = classificationForClaim(claimedVersion); + if (classification.era === 'modern' && request.mcpMethodHeader !== undefined && request.mcpMethodHeader !== method) { + return crossCheckMismatch( + 'notification-method-header-mismatch', + request.mcpMethodHeader, + `the notification body names method ${method} but the Mcp-Method header names ${request.mcpMethodHeader}` + ); + } + return { kind: 'modern', messageKind: 'notification', classification }; + } + + // Notifications carry no body claim under the current spec, so the + // protocol-version header is determinative for them: a modern header + // routes the notification to modern serving; a missing or legacy header + // keeps it legacy traffic. The Mcp-Method header is validated only when + // the notification classifies modern — it is never enforced on legacy + // notifications. + if (headerNamesModern) { + if (request.mcpMethodHeader !== undefined && request.mcpMethodHeader !== method) { + return crossCheckMismatch( + 'notification-method-header-mismatch', + request.mcpMethodHeader, + `the notification body names method ${method} but the Mcp-Method header names ${request.mcpMethodHeader}` + ); + } + return { + kind: 'modern', + messageKind: 'notification', + classification: { era: 'modern', revision: headerVersion } + }; + } + return { kind: 'legacy', reason: 'notification', ...(headerVersion !== undefined && { requestedVersion: headerVersion }) }; +} + +/** + * Classifies one inbound HTTP request for dual-era serving. + * + * The body-primary predicate, evaluated once at the entry boundary: see the + * module documentation for the rules. Returns a routing outcome (`legacy` or + * `modern`) or a ladder rejection; it never throws. + */ +export function classifyInboundRequest(request: InboundHttpRequest): InboundClassificationOutcome { + if (request.httpMethod.toUpperCase() !== 'POST') { + // Body-less 2025-era session operations (and any other non-POST + // method): the modern era is POST-only. + return { kind: 'legacy', reason: 'http-method' }; + } + + const body = request.body; + if (Array.isArray(body)) { + return classifyBatch(body); + } + if (isJSONRPCResultResponse(body) || isJSONRPCErrorResponse(body)) { + // Posted responses are 2025-era session traffic (replies to + // server-initiated requests over a session); the modern era has no + // such channel. + return { kind: 'legacy', reason: 'response' }; + } + if (isPlainObject(body) && isJSONRPCRequest(body)) { + return classifyRequestBody(request, body); + } + if (isPlainObject(body) && isJSONRPCNotification(body)) { + return classifyNotificationBody(request, body); + } + return rejection( + 'jsonrpc-shape', + 'invalid-json-rpc-body', + 400, + new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Bad Request: the request body is not a valid JSON-RPC message'), + true + ); +} + +/* ------------------------------------------------------------------------ * + * Per-message classification (long-lived channels) + * ------------------------------------------------------------------------ */ + +/** + * Classifies one inbound JSON-RPC message for a long-lived dual-era channel + * (stdio and other hand-wired transports with no HTTP edge): the body-primary + * predicate reduced to its per-message form — there is no header layer (the + * stdio transport carries all request metadata inline in the message body) + * and no HTTP method to route. + * + * - `initialize` is the legacy handshake by definition; the version it + * requested is carried as the classification's `revision`. + * - A message whose `params._meta` carries the reserved protocol-version key + * claims the per-request envelope mechanism and classifies into the era of + * the named revision. Envelope validity is enforced at dispatch by the era + * codec — a malformed envelope behind a present claim is a validation + * error, never a silent fall back to legacy handling. + * - A message without that claim — including one carrying only + * `progressToken` or other non-reserved `_meta` keys — is legacy-era + * traffic. + * + * Pure and total over requests and notifications; consumed by the + * protocol-layer classification consult for dual-era server instances. + */ +export function classifyInboundMessage(message: { method: string; params?: unknown }): MessageClassification { + if (message.method === 'initialize') { + const params = message.params; + const requestedVersion = + isPlainObject(params) && typeof params['protocolVersion'] === 'string' ? params['protocolVersion'] : undefined; + // The classification's `revision` names the wire revision the message + // is classified INTO, so it only carries the requested version when + // that version is itself a legacy one — an `initialize` requesting a + // modern revision is still the legacy handshake (it never negotiates + // a modern era) and stays a bare legacy classification. + const legacyRevision = requestedVersion !== undefined && !isModernProtocolVersion(requestedVersion) ? requestedVersion : undefined; + return { era: 'legacy', ...(legacyRevision !== undefined && { revision: legacyRevision }) }; + } + if (hasEnvelopeClaim(message.params)) { + return classificationForClaim(envelopeClaimVersion(message.params)); + } + return { era: 'legacy' }; +} + +/* ------------------------------------------------------------------------ * + * Modern-only (strict) mapping of legacy routes + * ------------------------------------------------------------------------ */ + +/** + * The rejection a modern-only endpoint (no legacy serving configured) + * answers a legacy-classified request with. + * + * - Envelope-less requests (including `initialize`) are answered with the + * unsupported-protocol-version error carrying the endpoint's supported + * versions and echoing the version the request named (when it named one — + * `requested` is omitted rather than fabricated when the request named no + * version at all), so a legacy client can discover what the endpoint serves + * from the error alone. + * - Posted responses and batch arrays are invalid requests on the modern era. + * - Non-`POST` methods are not allowed. + * - Legacy-classified notifications return `undefined`: the caller answers + * 202 with no body and does not dispatch the notification (accept-and-drop). + */ +export function modernOnlyStrictRejection( + route: InboundLegacyRoute, + supportedVersions: readonly string[] +): InboundLadderRejection | undefined { + switch (route.reason) { + case 'http-method': { + return rejection('http-method', 'modern-only-method-not-allowed', 405, new ProtocolError(-32_000, 'Method not allowed.'), true); + } + case 'batch': { + return rejection( + 'jsonrpc-shape', + 'modern-only-batch-not-supported', + 400, + new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Bad Request: JSON-RPC batches are not supported by this endpoint'), + true + ); + } + case 'response': { + return rejection( + 'jsonrpc-shape', + 'modern-only-response-post', + 400, + new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Bad Request: JSON-RPC responses cannot be posted to this endpoint'), + true + ); + } + case 'notification': { + return undefined; + } + case 'initialize': + case 'no-claim': { + // `requested` reflects what the request actually named (an + // initialize body's `protocolVersion` or the protocol-version + // header); when the request named no version at all the field is + // omitted rather than fabricated. + const requested = route.requestedVersion; + const error = + requested === undefined + ? new ProtocolError( + ProtocolErrorCode.UnsupportedProtocolVersion, + 'Unsupported protocol version: the request did not name a protocol version', + { supported: [...supportedVersions] } + ) + : new UnsupportedProtocolVersionError({ supported: [...supportedVersions], requested }); + return rejection('era-classification', 'modern-only-missing-envelope', 400, error, true); + } + } +} diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 16d2181018..d2335a6065 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -15,6 +15,7 @@ import type { JSONRPCResponse, JSONRPCResultResponse, LoggingLevel, + MessageClassification, MessageExtraInfo, Notification, NotificationMethod, @@ -24,6 +25,7 @@ import type { Request, RequestId, RequestMeta, + RequestMetaEnvelope, RequestMethod, RequestTypeMap, Result, @@ -31,19 +33,23 @@ import type { ServerCapabilities } from '../types/index.js'; import { - getNotificationSchema, - getRequestSchema, - getResultSchema, + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse, + LOG_LEVEL_META_KEY, + PROTOCOL_VERSION_META_KEY, ProtocolError, ProtocolErrorCode, SUPPORTED_PROTOCOL_VERSIONS } from '../types/index.js'; import type { StandardSchemaV1 } from '../util/standardSchema.js'; import { isStandardSchema, validateStandardSchema } from '../util/standardSchema.js'; +import { bootstrapOutboundCodec } from '../wire/bootstrap.js'; +import type { LiftedWireMaterial, WireCodec } from '../wire/codec.js'; +import { classifiedWireEra, codecForVersion, isSpecNotificationMethod, isSpecRequestMethod } from '../wire/codec.js'; import type { Transport, TransportSendOptions } from './transport.js'; /** @@ -56,8 +62,10 @@ export type ProgressCallback = (progress: Progress) => void; */ export type ProtocolOptions = { /** - * Protocol versions supported. First version is preferred (sent by client, - * used as fallback by server). Passed to transport during {@linkcode Protocol.connect | connect()}. + * Protocol versions supported. The legacy `initialize` handshake offers and + * falls back to the first 2025-era entry in the list (the client sends it, + * the server counter-offers it); 2026-era entries are only ever selected via + * `server/discover`. Passed to transport during {@linkcode Protocol.connect | connect()}. * * @default {@linkcode SUPPORTED_PROTOCOL_VERSIONS} */ @@ -131,6 +139,79 @@ export type NotificationOptions = { relatedRequestId?: RequestId; }; +/** + * The reserved per-request `_meta` envelope keys (protocol revision + * 2026-07-28). The protocol layer lifts these out of inbound `_meta` before + * handlers run and surfaces them at `ctx.mcpReq.envelope` — they are + * wire-level bookkeeping, not handler material. + */ +const RESERVED_ENVELOPE_META_KEYS: readonly string[] = [ + PROTOCOL_VERSION_META_KEY, + CLIENT_INFO_META_KEY, + CLIENT_CAPABILITIES_META_KEY, + LOG_LEVEL_META_KEY +]; + +/** + * Top-level params members carrying multi-round-trip driver material + * (protocol revision 2026-07-28). The spec reserves these names on + * client-initiated REQUESTS only — notification params keep them untouched + * (a vendor notification may legitimately use the same names). + */ +const RETRY_PARAMS_KEYS = ['inputResponses', 'requestState'] as const; + +/** + * Lift wire-only material out of an inbound message so handlers see exactly + * the 2025-era shape, and surface it for the protocol layer (requests: via + * `ctx.mcpReq`). What counts as wire-only depends on the message kind: the + * reserved envelope `_meta` keys are reserved on every message, while the + * multi-round-trip retry fields (`inputResponses`/`requestState`) are + * reserved on client-initiated requests only — so notifications get only the + * envelope lift, and their top-level params stay untouched. Messages without + * wire-only material are returned unchanged (same reference). + */ +function liftWireOnlyMaterial( + message: T, + kind: 'request' | 'notification' +): { message: T; lifted: LiftedWireMaterial } { + const params = (message as { params?: unknown }).params; + if (!isPlainObject(params)) return { message, lifted: {} }; + + const meta = params._meta; + const envelopeKeys = isPlainObject(meta) ? RESERVED_ENVELOPE_META_KEYS.filter(key => key in meta) : []; + const retryKeys = kind === 'request' ? RETRY_PARAMS_KEYS.filter(key => key in params) : []; + if (envelopeKeys.length === 0 && retryKeys.length === 0) return { message, lifted: {} }; + + const lifted: LiftedWireMaterial = {}; + const nextParams: Record = { ...params }; + + if (envelopeKeys.length > 0 && isPlainObject(meta)) { + const envelope: Record = {}; + const nextMeta: Record = { ...meta }; + for (const key of envelopeKeys) { + envelope[key] = meta[key]; + delete nextMeta[key]; + } + // Surfaced as received; validation/enforcement is the dispatch-time + // classifier's job, not the lift's. + lifted.envelope = envelope as Partial; + if (Object.keys(nextMeta).length > 0) { + nextParams._meta = nextMeta; + } else { + delete nextParams._meta; + } + } + + for (const key of retryKeys) { + // Driver material reaches the protocol layer un-deleted, verbatim. + if (key === 'inputResponses') lifted.inputResponses = nextParams[key] as Record; + if (key === 'requestState') lifted.requestState = nextParams[key] as string; + delete nextParams[key]; + } + + return { message: { ...message, params: nextParams } as T, lifted }; +} + /** * Base context provided to all request handlers. */ @@ -155,10 +236,37 @@ export type BaseContext = { method: string; /** - * Metadata from the original request. + * Metadata from the original request, with the reserved + * `io.modelcontextprotocol/*` envelope keys already lifted out + * (readable via `ctx.mcpReq.envelope`). */ _meta?: RequestMeta; + /** + * The per-request `_meta` envelope (protocol revision 2026-07-28): + * the reserved `io.modelcontextprotocol/*` keys carried by the + * request, lifted out of the `_meta` the handler sees. Surfaced as + * received — `Partial` because only the keys the request actually + * carried are present (envelope requiredness is enforced per request + * at dispatch time, not by the lift); only present at all when the + * request carried envelope keys. + */ + envelope?: Partial; + + /** + * Multi-round-trip input responses carried by a retried request + * (protocol revision 2026-07-28), lifted out of the params the + * handler sees. Driver material — present verbatim when sent. + */ + inputResponses?: Record; + + /** + * Multi-round-trip request state echoed by a retried request + * (protocol revision 2026-07-28), lifted out of the params the + * handler sees. Driver material — present verbatim when sent. + */ + requestState?: string; + /** * An abort signal used to communicate if the request was cancelled from the sender's side. */ @@ -273,6 +381,32 @@ type TimeoutInfo = { onTimeout: () => void; }; +/* + * Package-internal write access to Protocol's negotiated-protocol-version state. + * + * The negotiated version is a protected field on Protocol that the role classes + * (Client/Server) assign directly. Tests and the modern-era server entry still + * need to set it from outside the class hierarchy, so Protocol's static + * initializer hands this module-scoped closure privileged access and + * `setNegotiatedProtocolVersion` re-exports it on the core INTERNAL barrel + * only — deliberately not public API. + */ +let writeNegotiatedProtocolVersion: (instance: Protocol, version: string | undefined) => void; + +/** + * Package-internal write channel for a {@linkcode Protocol} instance's + * negotiated protocol version, for callers outside the class hierarchy: + * tests and the (future) modern-era server entry that marks a factory + * instance modern at binding time. Exported on the core internal barrel + * only — never public API. + */ +export function setNegotiatedProtocolVersion( + instance: Protocol, + version: string | undefined +): void { + writeNegotiatedProtocolVersion(instance, version); +} + /** * Implements MCP protocol framing on top of a pluggable transport, including * features like request/response linking, notifications, and progress. @@ -285,12 +419,26 @@ export abstract class Protocol { private _requestMessageId = 0; private _requestHandlers: Map Promise> = new Map(); private _requestHandlerAbortControllers: Map = new Map(); - private _notificationHandlers: Map Promise> = new Map(); + private _notificationHandlers: Map Promise> = new Map(); private _responseHandlers: Map void> = new Map(); private _progressHandlers: Map = new Map(); private _timeoutInfo: Map = new Map(); private _pendingDebouncedNotifications = new Set(); + /** + * The protocol version negotiated for the current connection (`undefined` + * before negotiation completes), which determines the wire era this + * instance speaks. Set by the SDK's negotiation and initialize paths + * (`Client.connect`, `Server._oninitialize`). + */ + protected _negotiatedProtocolVersion?: string; + + static { + writeNegotiatedProtocolVersion = (instance, version) => { + instance._negotiatedProtocolVersion = version; + }; + } + protected _supportedProtocolVersions: string[]; /** @@ -341,6 +489,28 @@ export abstract class Protocol { */ protected abstract buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ContextT; + /** + * Classification consult for inbound messages whose transport did not + * classify them at the edge — long-lived dual-era channels such as stdio, + * where the protocol era is decided per message rather than per request + * at an HTTP edge. + * + * Consulted ONLY when the transport supplied no + * {@linkcode MessageExtraInfo.classification}: an edge classification + * always wins and the hook is never reached for it. The returned + * classification populates the carrier; on an instance with no negotiated + * protocol version it also selects the wire era for this one message, + * while an instance bound to a negotiated version validates it exactly + * like an edge classification (a mismatch is the typed + * unsupported-protocol-version answer for requests, a drop for + * notifications). Returning `'drop'` discards the message without writing + * any response. The base implementation returns `undefined`: unclassified + * traffic keeps today's dispatch path unchanged. + */ + protected _classifyInbound(_message: JSONRPCRequest | JSONRPCNotification): MessageClassification | 'drop' | undefined { + return undefined; + } + private async _oncancel(notification: CancelledNotification): Promise { if (!notification.params.requestId) { return; @@ -423,7 +593,7 @@ export abstract class Protocol { } else if (isJSONRPCRequest(message)) { this._onrequest(message, extra); } else if (isJSONRPCNotification(message)) { - this._onnotification(message); + this._onnotification(message, extra); } else { this._onerror(new Error(`Unknown message type: ${JSON.stringify(message)}`)); } @@ -470,44 +640,203 @@ export abstract class Protocol { this.onerror?.(error); } - private _onnotification(notification: JSONRPCNotification): void { - const handler = this._notificationHandlers.get(notification.method) ?? this.fallbackNotificationHandler; + private _onnotification(rawNotification: JSONRPCNotification, extra?: MessageExtraInfo): void { + // Hide wire-only material from notification handlers too — but ONLY + // the reserved envelope `_meta` keys (the retry params names are + // reserved on requests, not notifications). There is no + // per-notification context, so the lifted envelope keys are dropped, + // not surfaced; the protocol layer owns them. + const { message: notification } = liftWireOnlyMaterial(rawNotification, 'notification'); + + // Era is instance state: the negotiated protocol version selects the + // codec for everything this connection receives (legacy until + // negotiated). An edge classification is never a per-message era + // switch — it is validated against the instance era below. + let codec = this._negotiatedWireCodec(); + + // Classification consult (only when the transport did not classify; + // an edge classification always wins and never reaches the hook). On + // an unbound instance the hook's classification selects the era for + // this one message (long-lived dual-era channels); a bound instance + // validates it below exactly like an edge classification. + if (extra?.classification === undefined) { + const consulted = this._classifyInbound(rawNotification); + if (consulted === 'drop') { + return; + } + if (consulted !== undefined) { + extra = { ...extra, classification: consulted }; + if (this._negotiatedProtocolVersion === undefined) { + codec = codecForVersion(classifiedWireEra(consulted)); + } + } + } + + // Edge→instance handoff check: a classification that disagrees with + // the instance era means the entry routed another era's traffic onto + // this instance. That is a routing error — drop the notification and + // surface it out of band; never serve it on a guessed era. + if (extra?.classification !== undefined) { + const classified = classifiedWireEra(extra.classification); + if (classified !== codec.era) { + this._onerror( + new Error( + `Era mismatch on inbound notification '${notification.method}': classified as ${classified} but this instance serves ${codec.era}` + ) + ); + return; + } + } + + // Era gate — deletions are physical: a spec notification that is not + // in this era's registry is dropped even when a handler is + // registered (notifications get no error response; silent drop is + // the protocol-correct outcome, matching today's unknown-method + // posture). Methods outside the spec universe are consumer-owned + // extension notifications and stay era-blind. + if (isSpecNotificationMethod(notification.method) && !codec.hasNotificationMethod(notification.method)) { + return; + } + + const handler = this._notificationHandlers.get(notification.method); + const fallback = this.fallbackNotificationHandler; // Ignore notifications not being subscribed to. - if (handler === undefined) { + if (handler === undefined && fallback === undefined) { return; } // Starting with Promise.resolve() puts any synchronous errors into the monad as well. Promise.resolve() - .then(() => handler(notification)) + .then(() => (handler === undefined ? fallback!(notification) : handler(notification, codec))) .catch(error => this._onerror(new Error(`Uncaught error in notification handler: ${error}`))); } - private _onrequest(request: JSONRPCRequest, extra?: MessageExtraInfo): void { - const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; + private _onrequest(rawRequest: JSONRPCRequest, extra?: MessageExtraInfo): void { + // Lift wire-only material before dispatch: handlers (including the + // fallback handler and the per-method schema parse) see exactly the + // 2025-era shape; the envelope and retry fields surface via ctx. + const { message: request, lifted } = liftWireOnlyMaterial(rawRequest, 'request'); + + // Era is instance state: the negotiated protocol version selects the + // codec for everything this connection receives (legacy until + // negotiated). An edge classification (Q2; produced at the HTTP + // entry) is never a per-message era switch — it is validated against + // the instance era below. Hand-wired legacy transports never + // classify, so their behavior is untouched. + let codec = this._negotiatedWireCodec(); + + // Classification consult (only when the transport did not classify; + // an edge classification always wins and never reaches the hook). On + // an unbound instance the hook's classification selects the era for + // this one message (long-lived dual-era channels); a bound instance + // validates it below exactly like an edge classification. + if (extra?.classification === undefined) { + const consulted = this._classifyInbound(rawRequest); + if (consulted === 'drop') { + this._onerror(new Error(`Dropped inbound request '${rawRequest.method}': not servable on this connection's protocol era`)); + return; + } + if (consulted !== undefined) { + extra = { ...extra, classification: consulted }; + if (this._negotiatedProtocolVersion === undefined) { + codec = codecForVersion(classifiedWireEra(consulted)); + } + } + } // Capture the current transport at request time to ensure responses go to the correct client const capturedTransport = this._transport; - const sendNotification = (notification: Notification, options?: NotificationOptions) => - this.notification(notification, { ...options, relatedRequestId: request.id }); - const sendRequest = (r: Request, resultSchema: U, options?: RequestOptions) => - this._requestWithSchema(r, resultSchema, { ...options, relatedRequestId: request.id }); - - if (handler === undefined) { + const sendErrorResponse = (code: number, message: string, data?: unknown) => { const errorResponse: JSONRPCErrorResponse = { jsonrpc: '2.0', id: request.id, - error: { - code: ProtocolErrorCode.MethodNotFound, - message: 'Method not found' - } + error: { code, message, ...(data !== undefined && { data }) } }; capturedTransport?.send(errorResponse).catch(error => this._onerror(new Error(`Failed to send an error response: ${error}`))); + }; + + // Edge→instance handoff check: a classification that disagrees with + // the instance era means the entry routed another era's traffic onto + // this instance. That is a routing error: answer with the typed era + // error (−32004 Unsupported protocol version) and surface it out of + // band — never serve the request on a guessed era. + if (extra?.classification !== undefined) { + const classified = classifiedWireEra(extra.classification); + if (classified !== codec.era) { + this._onerror( + new Error( + `Era mismatch on inbound request '${request.method}': classified as ${classified} but this instance serves ${codec.era}` + ) + ); + // `requested` echoes the protocol version the classification + // actually named when it carried one; the wire-era label is + // only the fallback for classifications without an exact + // revision. + const requested = extra.classification.revision ?? classified; + sendErrorResponse(ProtocolErrorCode.UnsupportedProtocolVersion, `Unsupported protocol version: ${requested}`, { + // Per spec, `supported` is the full list of protocol + // versions the receiver supports — not just the version + // this connection is on — so the peer can pick a mutually + // supported version from the error alone. (Revisit when + // instances are bound to the modern era at the entry: a + // bound instance's configured list may not name the + // revision it was bound to.) + supported: this._supportedProtocolVersions, + requested + }); + return; + } + } + + // Era gate — deletions are physical: a spec method that is not in + // this era's registry is −32601 BY ABSENCE, before any handler + // lookup, even when a handler is registered (a custom handler cannot + // shadow a deleted spec method across eras). Methods outside the + // spec universe are consumer-owned extension methods and stay + // era-blind. + if (isSpecRequestMethod(request.method) && !codec.hasRequestMethod(request.method)) { + sendErrorResponse(ProtocolErrorCode.MethodNotFound, 'Method not found'); + return; + } + + const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; + + if (handler === undefined) { + sendErrorResponse(ProtocolErrorCode.MethodNotFound, 'Method not found'); return; } + // Envelope enforcement: the 2026 era requires the per-request `_meta` + // envelope on every request (spec.types.2026-07-28 RequestParams). + // The lift extracted it above; the era codec validates requiredness. + // Deliberately AFTER the era gate and the handler-existence check: + // an unknown method answers −32601 even when the envelope is also + // missing — method existence outranks parameter validity. (The + // canonical precedence table for the full inbound validation ladder + // arrives with the validation-ladder milestone; this site encodes + // only the −32601-over-−32602 rule.) + const envelopeError = codec.checkInboundEnvelope(lifted); + if (envelopeError !== undefined) { + sendErrorResponse(ProtocolErrorCode.InvalidParams, envelopeError); + return; + } + + // Related sends resolve through the SAME instance era as every other + // sender (the per-request/instance asymmetry is deliberately gone): + // the codec is resolved at send time from the connection state. + const sendNotification = (notification: Notification, options?: NotificationOptions) => + this._notificationViaCodec(this._resolveOutboundCodec(notification.method), notification, { + ...options, + relatedRequestId: request.id + }); + const sendRequest = (r: Request, resultSchema: U, options?: RequestOptions) => + this._requestWithSchemaViaCodec(this._resolveOutboundCodec(r.method), r, resultSchema, { + ...options, + relatedRequestId: request.id + }); + const abortController = new AbortController(); this._requestHandlerAbortControllers.set(request.id, abortController); @@ -517,16 +846,24 @@ export abstract class Protocol { id: request.id, method: request.method, _meta: request.params?._meta, + ...(lifted.envelope !== undefined && { envelope: lifted.envelope }), + ...(lifted.inputResponses !== undefined && { inputResponses: lifted.inputResponses }), + ...(lifted.requestState !== undefined && { requestState: lifted.requestState }), signal: abortController.signal, // BaseContext.mcpReq.send is declared with two overloads (spec-method-keyed and explicit-schema). Arrow // literals can't carry overload signatures, so the inferred single-signature type isn't assignable to // that overloaded property type. The cast is sound: this impl dispatches both overload paths via the // isStandardSchema guard, and sendRequest validates the result against the resolved schema either way. send: ((r: Request, schemaOrOptions?: StandardSchemaV1 | RequestOptions, maybeOptions?: RequestOptions) => { + // Related requests resolve through the instance era at + // send time, exactly like direct sends: era-gate first, + // then method-keyed schema resolution. + const sendCodec = this._resolveOutboundCodec(r.method); + this._assertOutboundRequestInEra(sendCodec, r.method); if (isStandardSchema(schemaOrOptions)) { return sendRequest(r, schemaOrOptions, maybeOptions); } - const resultSchema = getResultSchema(r.method); + const resultSchema = sendCodec.resultSchema(r.method); if (!resultSchema) { throw new TypeError( `'${r.method}' is not a spec method; pass a result schema as the second argument to ctx.mcpReq.send().` @@ -550,8 +887,25 @@ export abstract class Protocol { return; } + // The outbound stamp seam: the era codec maps the neutral + // handler result to its wire shape. The 2025-era codec is + // the identity (never-stamp); the 2026-era codec stamps + // `resultType` and enforces the deleted-field set. A throw + // here is a NEW failure mode between handler success and + // the transport send (and the seam grows ttlMs/cacheScope + // stamping content in M3.2) — it must answer the peer with + // −32603 rather than stranding the request until timeout. + let encoded: Result; + try { + encoded = codec.encodeResult(request.method, result); + } catch (error) { + this._onerror(new Error(`Failed to encode result for ${request.method}: ${error}`)); + sendErrorResponse(ProtocolErrorCode.InternalError, 'Internal error'); + return; + } + const response: JSONRPCResponse = { - result, + result: encoded, jsonrpc: '2.0', id: request.id }; @@ -685,26 +1039,91 @@ export abstract class Protocol { options?: RequestOptions ): Promise>; request(request: Request, schemaOrOptions?: StandardSchemaV1 | RequestOptions, maybeOptions?: RequestOptions): Promise { + const codec = this._resolveOutboundCodec(request.method); + this._assertOutboundRequestInEra(codec, request.method); if (isStandardSchema(schemaOrOptions)) { - return this._requestWithSchema(request, schemaOrOptions, maybeOptions); + return this._requestWithSchemaViaCodec(codec, request, schemaOrOptions, maybeOptions); } - const resultSchema = getResultSchema(request.method); + const resultSchema = codec.resultSchema(request.method); if (!resultSchema) { throw new TypeError(`'${request.method}' is not a spec method; pass a result schema as the second argument to request().`); } - return this._requestWithSchema(request, resultSchema, schemaOrOptions); + return this._requestWithSchemaViaCodec(codec, request, resultSchema, schemaOrOptions); + } + + /** + * The wire codec for this instance's negotiated era — the phase-2 truth: + * everything an established connection sends and receives resolves + * through it. Legacy until a version has been negotiated. + */ + private _negotiatedWireCodec(): WireCodec { + return codecForVersion(this._negotiatedProtocolVersion); } /** - * Sends a request and waits for a response, using the provided schema for validation. + * Outbound codec resolution: while the negotiated version is still unset + * (the negotiation window), lifecycle messages are bootstrap-pinned BY + * METHOD — they self-identify their era (`initialize` IS the legacy + * handshake, `server/discover` IS the modern probe). Once a version has + * been negotiated, the instance era is authoritative for everything — a + * negotiated session never re-routes a method onto the other era. + */ + private _resolveOutboundCodec(method: string): WireCodec { + if (this._negotiatedProtocolVersion === undefined) { + const pinned = bootstrapOutboundCodec(method); + if (pinned) return pinned; + } + return this._negotiatedWireCodec(); + } + + /** + * Era gate for outbound requests — deletions are physical in BOTH + * directions: sending a spec method that the resolved era does not define + * dies locally with a typed error before anything reaches the transport. + * Methods outside the spec universe are consumer-owned extension methods + * and stay era-blind. + */ + private _assertOutboundRequestInEra(codec: WireCodec, method: string): void { + if (isSpecRequestMethod(method) && !codec.hasRequestMethod(method)) { + throw new SdkError( + SdkErrorCode.MethodNotSupportedByProtocolVersion, + `Method '${method}' is not supported by the negotiated protocol version (wire era ${codec.era})`, + { method, era: codec.era } + ); + } + } + + /** + * Sends a request and waits for a response, using the provided schema for + * validation instead of the era registry's method-keyed entry. * - * This is the internal implementation used by SDK methods that need to specify - * a particular result schema (e.g., for compatibility schemas). + * This is the internal implementation used by SDK methods whose result + * schema cannot be expressed as a method-keyed registry entry — the one + * surviving case is `server.createMessage`, whose result schema depends + * on the REQUEST params (tools vs no tools) — and by callers passing + * explicit compatibility schemas. Spec methods are still era-gated here: + * an explicit schema never smuggles a deleted method onto the wire. */ protected _requestWithSchema( request: Request, resultSchema: T, options?: RequestOptions + ): Promise> { + const codec = this._resolveOutboundCodec(request.method); + this._assertOutboundRequestInEra(codec, request.method); + return this._requestWithSchemaViaCodec(codec, request, resultSchema, options); + } + + /** + * The request funnel proper, keyed by the resolved era codec: the codec + * owns result decoding (raw-first `resultType` discrimination — V-1 — + * and the era's lift posture) before the schema validation step. + */ + private _requestWithSchemaViaCodec( + codec: WireCodec, + request: Request, + resultSchema: T, + options?: RequestOptions ): Promise> { const { relatedRequestId, resumptionToken, onresumptiontoken } = options ?? {}; @@ -789,7 +1208,43 @@ export abstract class Protocol { return reject(response); } - validateStandardSchema(resultSchema, response.result).then(parseResult => { + // Codec decode hop — the structural V-1 home. The era codec + // owns the raw-first resultType postures (Q1-SD3): + // - 2026 era: REQUIRED discriminator; absent → typed error + // naming the spec violation; input_required → driver seam; + // unknown kind → invalid, no retry; complete → wire-exact + // parse then lift. + // - 2025 era: resultType is foreign vocabulary → strip-on- + // lift, then today's schema validation decides. + // Either way a non-complete body can never be masked into a + // hollow success by a tolerant result schema. + // Guarded: this callback runs synchronously inside + // `_onresponse`, so a throw out of the decode hop would + // otherwise propagate into the transport's onmessage instead + // of failing this request. + let decoded: ReturnType; + try { + decoded = codec.decodeResult(request.method, response.result); + } catch (error) { + return reject(error instanceof Error ? error : new Error(String(error))); + } + if (decoded.kind === 'invalid') { + return reject(decoded.error); + } + if (decoded.kind === 'input_required') { + // Driver seam: the multi-round-trip driver (M4.1) + // consumes this payload; until it lands, surface the + // discriminated kind as a typed local error, no retry. + return reject( + new SdkError(SdkErrorCode.UnsupportedResultType, `Unsupported result type 'input_required' for ${request.method}`, { + resultType: 'input_required', + method: request.method + }) + ); + } + const result = decoded.result; + + validateStandardSchema(resultSchema, result).then(parseResult => { if (parseResult.success) { resolve(parseResult.data); } else { @@ -829,10 +1284,29 @@ export abstract class Protocol { * Emits a notification, which is a one-way message that does not expect a response. */ async notification(notification: Notification, options?: NotificationOptions): Promise { + return this._notificationViaCodec(this._resolveOutboundCodec(notification.method), notification, options); + } + + /** + * The notification funnel proper, keyed by the resolved era codec — + * direct sends and related notifications (`ctx.mcpReq.notify`) alike + * resolve through the instance's negotiated era at send time. + */ + private async _notificationViaCodec(codec: WireCodec, notification: Notification, options?: NotificationOptions): Promise { if (!this._transport) { throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); } + // Era gate — outbound deletions are physical for notifications too: a + // spec notification the resolved era does not define dies locally. + if (isSpecNotificationMethod(notification.method) && !codec.hasNotificationMethod(notification.method)) { + throw new SdkError( + SdkErrorCode.MethodNotSupportedByProtocolVersion, + `Notification '${notification.method}' is not supported by the negotiated protocol version (wire era ${codec.era})`, + { method: notification.method, era: codec.era } + ); + } + this.assertNotificationCapability(notification.method); const jsonrpcNotification: JSONRPCNotification = { jsonrpc: '2.0', ...notification }; @@ -914,18 +1388,32 @@ export abstract class Protocol { let stored: (request: JSONRPCRequest, ctx: ContextT) => Promise; if (typeof schemasOrHandler === 'function') { - const schema = getRequestSchema(method); - if (!schema) { + if (!isSpecRequestMethod(method)) { throw new TypeError( `'${method}' is not a spec request method; pass schemas as the second argument to setRequestHandler().` ); } - stored = (request, ctx) => Promise.resolve(schemasOrHandler(schema.parse(request), ctx)); + // Dispatch-time schema resolution: the request is parsed with the + // schema of the era serving this connection (the instance era at + // dispatch time), never with a schema captured at registration + // time. + stored = (request, ctx) => { + const schema = this._negotiatedWireCodec().requestSchema(method); + if (!schema) { + // Unreachable: the dispatch era gate rejects era-mismatched + // spec methods with −32601 before any handler runs. + throw new ProtocolError(ProtocolErrorCode.InternalError, `No wire schema for ${method} in the resolved era`); + } + return Promise.resolve(schemasOrHandler(schema.parse(request), ctx)); + }; } else if (maybeHandler) { stored = async (request, ctx) => { - const userParams = { ...request.params }; - delete userParams._meta; - const parsed = await validateStandardSchema(schemasOrHandler.params, userParams); + // Custom handlers receive `_meta` present-minus-reserved: the + // wire-only lift already removed the reserved envelope keys, + // and the remaining metadata (progressToken, extension keys) + // is handler material — consistent with the spec-method path. + // (Behavior migration: `_meta` used to be deleted here.) + const parsed = await validateStandardSchema(schemasOrHandler.params, { ...request.params }); if (!parsed.success) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error}`); } @@ -978,7 +1466,9 @@ export abstract class Protocol { * spec schema. For custom (non-spec) methods, pass `(method, schemas, handler)`; * `params` are validated against `schemas.params` and the handler receives the * parsed params object directly. The raw notification is passed as the second - * argument; `_meta` is recoverable via `notification.params?._meta`. + * argument; `_meta` is recoverable via `notification.params?._meta` (minus the + * reserved `io.modelcontextprotocol/*` envelope keys, which the protocol layer + * lifts out before dispatch). */ setNotificationHandler( method: M, @@ -995,13 +1485,22 @@ export abstract class Protocol { maybeHandler?: (params: unknown, notification: Notification) => void | Promise ): void { if (typeof schemasOrHandler === 'function') { - const schema = getNotificationSchema(method); - if (!schema) { + if (!isSpecNotificationMethod(method)) { throw new TypeError( `'${method}' is not a spec notification method; pass schemas as the second argument to setNotificationHandler().` ); } - this._notificationHandlers.set(method, notification => Promise.resolve(schemasOrHandler(schema.parse(notification)))); + // Dispatch-time schema resolution, same as setRequestHandler: the + // era serving the message picks the schema. + this._notificationHandlers.set(method, (notification, codec) => { + const schema = codec.notificationSchema(method); + if (!schema) { + // Unreachable: the dispatch era gate drops era-mismatched + // spec notifications before any handler runs. + throw new ProtocolError(ProtocolErrorCode.InternalError, `No wire schema for ${method} in the resolved era`); + } + return Promise.resolve(schemasOrHandler(schema.parse(notification))); + }); return; } @@ -1009,9 +1508,9 @@ export abstract class Protocol { throw new TypeError('setNotificationHandler: handler is required'); } this._notificationHandlers.set(method, async notification => { - const userParams = { ...notification.params }; - delete userParams._meta; - const parsed = await validateStandardSchema(schemasOrHandler.params, userParams); + // `_meta` present-minus-reserved, matching the custom request + // path (the lift already removed the reserved envelope keys). + const parsed = await validateStandardSchema(schemasOrHandler.params, { ...notification.params }); if (!parsed.success) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for notification ${method}: ${parsed.error}`); } diff --git a/packages/core/src/shared/protocolEras.ts b/packages/core/src/shared/protocolEras.ts new file mode 100644 index 0000000000..a85135fa06 --- /dev/null +++ b/packages/core/src/shared/protocolEras.ts @@ -0,0 +1,41 @@ +/** + * Protocol-era helpers (pure module). The MCP wire protocol splits into two eras: + * legacy (the 2025-11-25 family and earlier; the version is negotiated via the + * `initialize` handshake) and modern (2026-07-28 and later; no `initialize` — + * servers advertise versions via `server/discover` and every request carries a + * `_meta` envelope). + * + * An operation that belongs to one era must only ever consult that era's subset + * of a supported-versions list: `initialize` never accepts or counter-offers a + * modern revision, and the `server/discover` advertisement only ever contains + * modern revisions. + */ + +/** + * The first protocol revision of the modern (2026-07-28) era. Revision identifiers + * are ISO dates, so lexicographic comparison orders them chronologically. + */ +export const FIRST_MODERN_PROTOCOL_VERSION = '2026-07-28'; + +/** + * Modern-era protocol revisions this SDK can negotiate via `server/discover`. + * Deliberately separate from {@linkcode SUPPORTED_PROTOCOL_VERSIONS} (the legacy + * `initialize` list), so adding a revision here can never leak a modern version + * string into a 2025-era handshake. Internal — not part of the public API surface. + */ +export const SUPPORTED_MODERN_PROTOCOL_VERSIONS = [FIRST_MODERN_PROTOCOL_VERSION]; + +/** Whether the given protocol revision belongs to the modern (2026-07-28+) era. */ +export function isModernProtocolVersion(version: string): boolean { + return version >= FIRST_MODERN_PROTOCOL_VERSION; +} + +/** The legacy-era (pre-2026-07-28) subset of a supported-versions list, in the list's own preference order. */ +export function legacyProtocolVersions(versions: readonly string[]): string[] { + return versions.filter(version => !isModernProtocolVersion(version)); +} + +/** The modern-era (2026-07-28+) subset of a supported-versions list, in the list's own preference order. */ +export function modernProtocolVersions(versions: readonly string[]): string[] { + return versions.filter(version => isModernProtocolVersion(version)); +} diff --git a/packages/core/src/shared/resultCacheHints.ts b/packages/core/src/shared/resultCacheHints.ts new file mode 100644 index 0000000000..a1786f3a35 --- /dev/null +++ b/packages/core/src/shared/resultCacheHints.ts @@ -0,0 +1,138 @@ +/** + * 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, + * 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 + * (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 safe 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. 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; + } + 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]: merged }; +} + +/** 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 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.isSafeInteger(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 safe 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/types/README.md b/packages/core/src/types/README.md new file mode 100644 index 0000000000..6d235ec8ae --- /dev/null +++ b/packages/core/src/types/README.md @@ -0,0 +1,26 @@ +# Spec reference types ("anchors") + +The `spec.types..ts` files in this directory are vendored, verbatim copies of the MCP specification's normative `schema.ts`, one file per protocol revision. Each file is generated by `pnpm run fetch:spec-types [version] [sha]` (`scripts/fetch-spec-types.ts`): the +upstream schema is fetched at a specific spec commit, a provenance header recording that commit is prepended, and the result is formatted with the project's prettier config — no other transformation. + +They are reference-only test oracles: the comparison suites in `packages/core/test/spec.types..test.ts` check the SDK's own types against them. They are not exported from any barrel and must never be imported by runtime code. + +## Lifecycle policy + +1. **Released revisions are frozen.** Once a protocol revision is published under `schema//` in the spec repository, its anchor regenerates only from the pinned spec commit recorded in `RELEASED_REVISION_PINS` (`scripts/fetch-spec-types.ts`) — never from the latest + upstream commit. Moving that pin, including the freeze of a newly published revision (when its generation source switches from `schema/draft/` to `schema//`), must land in the same commit that retargets the nightly update workflow + (`.github/workflows/update-spec-types.yml`), so the anchor and the automation that maintains it can never disagree about the source of truth. + +2. **Draft anchors float only via reviewed refresh PRs.** The anchor for an unreleased revision tracks the spec repository's `schema/draft/schema.ts`. The nightly workflow regenerates it from the latest upstream commit and, when the result differs from what is checked in, opens + (or updates) a refresh PR. Manual refreshes follow the same path: regenerate, then propose the diff in a PR. + +3. **The bot proposes; it never auto-merges.** Automated refreshes always go through a pull request that a maintainer reviews and merges. No automation pushes anchor changes directly to `main` or merges its own PRs. A refresh PR that breaks the comparison suites is the desired + signal — it is fixed in that PR, not bypassed. + +4. **Generated twins update atomically with their anchor.** If artifacts derived from an anchor (for example vendored JSON schemas or generated validators) are checked into this repository, any refresh that changes the anchor must regenerate those artifacts in the same commit. + The anchor and its derived twins must never be out of sync at any commit on `main`. + + **This clause is OPERATIVE.** The vendored twins are the per-revision `schema.json` copies under `packages/core/test/corpus/schema-twins/` (`.schema.json` + `manifest.json` recording the source commit and content hashes). They are TEST-ONLY oracles consumed by the + schema-twin conformance lock (`test/wire/schemaTwinConformance.test.ts`) — never bundled, never imported by runtime code, and the JSON Schema engines stay optional peer dependencies. A refresh of `spec.types..ts` must copy the matching upstream + `schema//schema.json` (same spec commit) over the twin and update `manifest.json` in the same commit; the spec example corpus manifest (`test/corpus/fixtures//manifest.json`) records its own source commit and follows the same atomicity rule when the examples + are re-vendored. The conformance lock failing after an anchor-only refresh is the desired loud signal of a missed twin update. diff --git a/packages/core/src/types/constants.ts b/packages/core/src/types/constants.ts index 018f9ecb51..109c5c4ee2 100644 --- a/packages/core/src/types/constants.ts +++ b/packages/core/src/types/constants.ts @@ -2,6 +2,11 @@ export const LATEST_PROTOCOL_VERSION = '2025-11-25'; export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = '2025-03-26'; export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']; +/** + * `_meta` key associating a message with a 2025-11-25 task. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ export const RELATED_TASK_META_KEY = 'io.modelcontextprotocol/related-task'; /* Reserved `_meta` keys for the per-request envelope (protocol revision 2026-07-28) */ 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/guards.ts b/packages/core/src/types/guards.ts index f385b91b42..8091b962c1 100644 --- a/packages/core/src/types/guards.ts +++ b/packages/core/src/types/guards.ts @@ -72,6 +72,12 @@ export const isJSONRPCResponse = (value: unknown): value is JSONRPCResponse => J /** * Checks if a value is a valid {@linkcode CallToolResult}. + * + * This is a consumer-side VALUE check against the neutral model, not a wire + * validator: a raw wire object that additionally carries wire-only members + * (e.g. `resultType`) still passes through the loose index signature. Use a + * transport-level parse to validate raw wire traffic. + * * @param value - The value to check. * * @returns True if the value is a valid {@linkcode CallToolResult}, false otherwise. @@ -86,6 +92,9 @@ export const isCallToolResult = (value: unknown): value is CallToolResult => { * @param value - The value to check. * * @returns True if the value is a valid {@linkcode TaskAugmentedRequestParams}, false otherwise. + * + * @deprecated Recognizes 2025-11-25 task wire vocabulary, which has no SDK + * runtime; kept importable for interoperability only. */ export const isTaskAugmentedRequestParams = (value: unknown): value is TaskAugmentedRequestParams => TaskAugmentedRequestParamsSchema.safeParse(value).success; diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index fe850284e2..22e405ff06 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -1,23 +1,7 @@ import * as z from 'zod/v4'; -import { - CLIENT_CAPABILITIES_META_KEY, - CLIENT_INFO_META_KEY, - JSONRPC_VERSION, - LOG_LEVEL_META_KEY, - PROTOCOL_VERSION_META_KEY, - RELATED_TASK_META_KEY -} from './constants.js'; -import type { - JSONArray, - JSONObject, - JSONValue, - NotificationMethod, - NotificationTypeMap, - RequestMethod, - RequestTypeMap, - ResultTypeMap -} from './types.js'; +import { JSONRPC_VERSION, RELATED_TASK_META_KEY } from './constants.js'; +import type { JSONArray, JSONObject, JSONValue } from './types.js'; export const JSONValueSchema: z.ZodType = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.record(z.string(), JSONValueSchema), z.array(JSONValueSchema)]) @@ -34,21 +18,7 @@ export const ProgressTokenSchema = z.union([z.string(), z.number().int()]); */ export const CursorSchema = z.string(); -/** - * Task creation parameters, used to ask that the server create a task to represent a request. - */ -export const TaskCreationParamsSchema = z.looseObject({ - /** - * Requested duration in milliseconds to retain task from creation. - */ - ttl: z.number().optional(), - - /** - * Time in milliseconds to wait between task status requests. - */ - pollInterval: z.number().optional() -}); - +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const TaskMetadataSchema = z.object({ ttl: z.number().optional() }); @@ -56,6 +26,8 @@ export const TaskMetadataSchema = z.object({ /** * Metadata for associating messages with a task. * Include this in the `_meta` field under the key `io.modelcontextprotocol/related-task`. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const RelatedTaskMetadataSchema = z.object({ taskId: z.string() @@ -84,6 +56,8 @@ export const BaseRequestParamsSchema = z.object({ /** * Common params for any task-augmented request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const TaskAugmentedRequestParamsSchema = BaseRequestParamsSchema.extend({ /** @@ -120,14 +94,13 @@ export const ResultSchema = z.looseObject({ * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) * for notes on `_meta` usage. */ - _meta: RequestMetaSchema.optional(), - /** - * Indicates the type of the result, allowing the receiver to determine how to - * parse the result object. Servers implementing protocol revision 2026-07-28 or - * later always include this field; results from earlier revisions omit it, and - * an absent value must be treated as `"complete"`. - */ - resultType: z.string().optional() + _meta: RequestMetaSchema.optional() + // `resultType` is wire-only vocabulary (protocol revision 2026-07-28) and + // is deliberately NOT modeled here: the neutral result schemas carry no + // slot for it. It exists only inside the 2026-era wire codec, which + // consumes it on decode and stamps it on encode. (Q1 increment 2 - the + // former optional member here was the masking surface that let modern + // vocabulary leak through every legacy-leg parse.) }); /** @@ -347,6 +320,8 @@ const ElicitationCapabilitySchema = z.preprocess( /** * Task capabilities for clients, indicating which request types support task creation. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const ClientTasksCapabilitySchema = z.looseObject({ /** @@ -384,6 +359,8 @@ export const ClientTasksCapabilitySchema = z.looseObject({ /** * Task capabilities for servers, indicating which request types support task creation. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export const ServerTasksCapabilitySchema = z.looseObject({ /** @@ -460,6 +437,8 @@ export const ClientCapabilitiesSchema = z.object({ .optional(), /** * Present if the client supports task creation. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; parsed for interoperability only — servers built on this SDK never advertise it. */ tasks: ClientTasksCapabilitySchema.optional(), /** @@ -544,6 +523,8 @@ export const ServerCapabilitiesSchema = z.object({ .optional(), /** * Present if the server supports task creation. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; parsed for interoperability only — servers built on this SDK never advertise it. */ tasks: ServerTasksCapabilitySchema.optional(), /** @@ -679,120 +660,6 @@ export const PaginatedResultSchema = ResultSchema.extend({ nextCursor: CursorSchema.optional() }); -/** - * The status of a task. - * */ -export const TaskStatusSchema = z.enum(['working', 'input_required', 'completed', 'failed', 'cancelled']); - -/* Tasks */ -/** - * A pollable state object associated with a request. - */ -export const TaskSchema = z.object({ - taskId: z.string(), - status: TaskStatusSchema, - /** - * Time in milliseconds to keep task results available after completion. - * If `null`, the task has unlimited lifetime until manually cleaned up. - */ - ttl: z.union([z.number(), z.null()]), - /** - * ISO 8601 timestamp when the task was created. - */ - createdAt: z.string(), - /** - * ISO 8601 timestamp when the task was last updated. - */ - lastUpdatedAt: z.string(), - pollInterval: z.optional(z.number()), - /** - * Optional diagnostic message for failed tasks or other status information. - */ - statusMessage: z.optional(z.string()) -}); - -/** - * Result returned when a task is created, containing the task data wrapped in a `task` field. - */ -export const CreateTaskResultSchema = ResultSchema.extend({ - task: TaskSchema -}); - -/** - * Parameters for task status notification. - */ -export const TaskStatusNotificationParamsSchema = NotificationsParamsSchema.merge(TaskSchema); - -/** - * A notification sent when a task's status changes. - */ -export const TaskStatusNotificationSchema = NotificationSchema.extend({ - method: z.literal('notifications/tasks/status'), - params: TaskStatusNotificationParamsSchema -}); - -/** - * A request to get the state of a specific task. - */ -export const GetTaskRequestSchema = RequestSchema.extend({ - method: z.literal('tasks/get'), - params: BaseRequestParamsSchema.extend({ - taskId: z.string() - }) -}); - -/** - * The response to a {@linkcode GetTaskRequest | tasks/get} request. - */ -export const GetTaskResultSchema = ResultSchema.merge(TaskSchema); - -/** - * A request to get the result of a specific task. - */ -export const GetTaskPayloadRequestSchema = RequestSchema.extend({ - method: z.literal('tasks/result'), - params: BaseRequestParamsSchema.extend({ - taskId: z.string() - }) -}); - -/** - * The response to a `tasks/result` request. - * The structure matches the result type of the original request. - * For example, a {@linkcode CallToolRequest | tools/call} task would return the `CallToolResult` structure. - * - */ -export const GetTaskPayloadResultSchema = ResultSchema.loose(); - -/** - * A request to list tasks. - */ -export const ListTasksRequestSchema = PaginatedRequestSchema.extend({ - method: z.literal('tasks/list') -}); - -/** - * The response to a {@linkcode ListTasksRequest | tasks/list} request. - */ -export const ListTasksResultSchema = PaginatedResultSchema.extend({ - tasks: z.array(TaskSchema) -}); - -/** - * A request to cancel a specific task. - */ -export const CancelTaskRequestSchema = RequestSchema.extend({ - method: z.literal('tasks/cancel'), - params: BaseRequestParamsSchema.extend({ - taskId: z.string() - }) -}); - -/** - * The response to a {@linkcode CancelTaskRequest | tasks/cancel} request. - */ -export const CancelTaskResultSchema = ResultSchema.merge(TaskSchema); - /* Resources */ /** * The contents of a specific resource or sub-resource. @@ -1432,9 +1299,9 @@ export const CallToolResultSchema = ResultSchema.extend({ * A list of content objects that represent the result of the tool call. * * If the `Tool` does not define an outputSchema, this field MUST be present in the result. - * For backwards compatibility, this field is always present, but it may be empty. + * Required on the wire per the specification (it may be an empty array). */ - content: z.array(ContentBlockSchema).default([]), + content: z.array(ContentBlockSchema), /** * An object containing structured tool output. @@ -1572,48 +1439,6 @@ export const LoggingMessageNotificationSchema = NotificationSchema.extend({ params: LoggingMessageNotificationParamsSchema }); -/* Per-request `_meta` envelope */ -/** - * The per-request `_meta` envelope carried by every request under protocol revision - * 2026-07-28: the protocol version governing the request, the client implementation - * info, and the client's capabilities — declared per request rather than once at - * initialization — plus the optional log-level opt-in. - * - * This schema models the complete envelope on its own. The base request schemas - * ({@linkcode RequestMetaSchema}) deliberately stay lenient so the same wire schemas - * parse requests from earlier protocol revisions (no envelope) as well; envelope - * requiredness is enforced per request at dispatch time, not here. - */ -export const RequestMetaEnvelopeSchema = z.looseObject({ - /** - * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. - */ - progressToken: ProgressTokenSchema.optional(), - /** - * The MCP protocol version being used for this request. For the HTTP transport, - * the value must match the `MCP-Protocol-Version` header. - */ - [PROTOCOL_VERSION_META_KEY]: z.string(), - /** - * Identifies the client software making the request. - */ - [CLIENT_INFO_META_KEY]: ImplementationSchema, - /** - * The client's capabilities for this specific request. An empty object means the - * client supports no optional capabilities. Servers must not infer capabilities - * from prior requests. - */ - [CLIENT_CAPABILITIES_META_KEY]: ClientCapabilitiesSchema, - /** - * The desired log level for this request. When absent, the server must not send - * `notifications/message` notifications for the request. - * - * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains - * in the specification for at least twelve months. - */ - [LOG_LEVEL_META_KEY]: LoggingLevelSchema.optional() -}); - /* Sampling */ /** * Hints to use for model selection. @@ -1667,7 +1492,7 @@ export const ToolChoiceSchema = z.object({ export const ToolResultContentSchema = z.object({ type: z.literal('tool_result'), toolUseId: z.string().describe('The unique identifier for the corresponding tool call.'), - content: z.array(ContentBlockSchema).default([]), + content: z.array(ContentBlockSchema), structuredContent: z.object({}).loose().optional(), isError: z.boolean().optional(), @@ -2181,9 +2006,16 @@ export const RootsListChangedNotificationSchema = NotificationSchema.extend({ }); /* Client messages */ +// NOTE (Q1 increment 2): the role unions below are the NEUTRAL message sets. +// The 2025-era task vocabulary (tasks/* methods, task results, the task +// status notification) is 2025-only WIRE vocabulary and now lives in +// `wire/rev2025-11-25/schemas.ts`, which also exports the era's full wire +// role unions. The deprecated Task* types remain importable from the types +// barrel (Q1-SD2); they appear in no role aggregate and no API signature. export const ClientRequestSchema = z.union([ PingRequestSchema, InitializeRequestSchema, + DiscoverRequestSchema, CompleteRequestSchema, SetLevelRequestSchema, GetPromptRequestSchema, @@ -2194,19 +2026,14 @@ export const ClientRequestSchema = z.union([ SubscribeRequestSchema, UnsubscribeRequestSchema, CallToolRequestSchema, - ListToolsRequestSchema, - GetTaskRequestSchema, - GetTaskPayloadRequestSchema, - ListTasksRequestSchema, - CancelTaskRequestSchema + ListToolsRequestSchema ]); export const ClientNotificationSchema = z.union([ CancelledNotificationSchema, ProgressNotificationSchema, InitializedNotificationSchema, - RootsListChangedNotificationSchema, - TaskStatusNotificationSchema + RootsListChangedNotificationSchema ]); export const ClientResultSchema = z.union([ @@ -2214,23 +2041,11 @@ export const ClientResultSchema = z.union([ CreateMessageResultSchema, CreateMessageResultWithToolsSchema, ElicitResultSchema, - ListRootsResultSchema, - GetTaskResultSchema, - ListTasksResultSchema, - CreateTaskResultSchema + ListRootsResultSchema ]); /* Server messages */ -export const ServerRequestSchema = z.union([ - PingRequestSchema, - CreateMessageRequestSchema, - ElicitRequestSchema, - ListRootsRequestSchema, - GetTaskRequestSchema, - GetTaskPayloadRequestSchema, - ListTasksRequestSchema, - CancelTaskRequestSchema -]); +export const ServerRequestSchema = z.union([PingRequestSchema, CreateMessageRequestSchema, ElicitRequestSchema, ListRootsRequestSchema]); export const ServerNotificationSchema = z.union([ CancelledNotificationSchema, @@ -2240,13 +2055,13 @@ export const ServerNotificationSchema = z.union([ ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, - TaskStatusNotificationSchema, ElicitationCompleteNotificationSchema ]); export const ServerResultSchema = z.union([ EmptyResultSchema, InitializeResultSchema, + DiscoverResultSchema, CompleteResultSchema, GetPromptResultSchema, ListPromptsResultSchema, @@ -2254,93 +2069,5 @@ export const ServerResultSchema = z.union([ ListResourceTemplatesResultSchema, ReadResourceResultSchema, CallToolResultSchema, - ListToolsResultSchema, - GetTaskResultSchema, - ListTasksResultSchema, - CreateTaskResultSchema + ListToolsResultSchema ]); - -/* Runtime schema lookup — result schemas by method */ -const resultSchemas: Record = { - ping: EmptyResultSchema, - initialize: InitializeResultSchema, - 'completion/complete': CompleteResultSchema, - 'logging/setLevel': EmptyResultSchema, - 'prompts/get': GetPromptResultSchema, - 'prompts/list': ListPromptsResultSchema, - 'resources/list': ListResourcesResultSchema, - 'resources/templates/list': ListResourceTemplatesResultSchema, - 'resources/read': ReadResourceResultSchema, - 'resources/subscribe': EmptyResultSchema, - 'resources/unsubscribe': EmptyResultSchema, - 'tools/call': z.union([CallToolResultSchema, CreateTaskResultSchema]), - 'tools/list': ListToolsResultSchema, - 'sampling/createMessage': z.union([CreateMessageResultWithToolsSchema, CreateTaskResultSchema]), - 'elicitation/create': z.union([ElicitResultSchema, CreateTaskResultSchema]), - 'roots/list': ListRootsResultSchema, - 'tasks/get': GetTaskResultSchema, - 'tasks/result': ResultSchema, - 'tasks/list': ListTasksResultSchema, - 'tasks/cancel': CancelTaskResultSchema -}; - -/** - * Gets the Zod schema for validating results of a given request method. - * Returns `undefined` for non-spec methods. - * @see getRequestSchema for explanation of the internal type assertion. - */ -export function getResultSchema(method: M): z.ZodType; -export function getResultSchema(method: string): z.ZodType | undefined; -export function getResultSchema(method: string): z.ZodType | undefined { - return resultSchemas[method as RequestMethod] as unknown as z.ZodType | undefined; -} - -/* Runtime schema lookup — request schemas by method */ -type RequestSchemaType = (typeof ClientRequestSchema.options)[number] | (typeof ServerRequestSchema.options)[number]; -type NotificationSchemaType = (typeof ClientNotificationSchema.options)[number] | (typeof ServerNotificationSchema.options)[number]; - -function buildSchemaMap(schemas: readonly T[]): Record { - const map: Record = {}; - for (const schema of schemas) { - const method = schema.shape.method.value; - map[method] = schema; - } - return map; -} - -const requestSchemas = buildSchemaMap([...ClientRequestSchema.options, ...ServerRequestSchema.options] as const) as Record< - RequestMethod, - RequestSchemaType ->; -const notificationSchemas = buildSchemaMap([...ClientNotificationSchema.options, ...ServerNotificationSchema.options] as const) as Record< - NotificationMethod, - NotificationSchemaType ->; - -/** - * Gets the Zod schema for a given request method. - * Returns `undefined` for non-spec methods. - * The return type is a ZodType that parses to RequestTypeMap[M], allowing callers - * to use schema.parse() without needing additional type assertions. - * - * Note: The internal cast is necessary because TypeScript can't correlate the - * Record-based schema lookup with the MethodToTypeMap-based RequestTypeMap - * when M is a generic type parameter. Both compute to the same type at - * instantiation, but TypeScript can't prove this statically. - */ -export function getRequestSchema(method: M): z.ZodType; -export function getRequestSchema(method: string): z.ZodType | undefined; -export function getRequestSchema(method: string): z.ZodType | undefined { - return requestSchemas[method as RequestMethod] as unknown as z.ZodType | undefined; -} - -/** - * Gets the Zod schema for a given notification method. - * Returns `undefined` for non-spec methods. - * @see getRequestSchema for explanation of the internal type assertion. - */ -export function getNotificationSchema(method: M): z.ZodType; -export function getNotificationSchema(method: string): z.ZodType | undefined; -export function getNotificationSchema(method: string): z.ZodType | undefined { - return notificationSchemas[method as NotificationMethod] as unknown as z.ZodType | undefined; -} diff --git a/packages/core/src/types/spec.types.2026-07-28.ts b/packages/core/src/types/spec.types.2026-07-28.ts index 7305df0462..1b222b9896 100644 --- a/packages/core/src/types/spec.types.2026-07-28.ts +++ b/packages/core/src/types/spec.types.2026-07-28.ts @@ -3,7 +3,7 @@ * * Source: https://github.com/modelcontextprotocol/modelcontextprotocol * Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/draft/schema.ts - * Last updated from commit: 9d700ed62dcf86cb77475c9b81930611a9182f46 + * Last updated from commit: 77cb26481e439d3437bc2bd6ccd19fcae86bb1ec * * DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates. * To update this file, run: pnpm run fetch:spec-types 2026-07-28 @@ -569,7 +569,7 @@ export interface DiscoverRequest extends JSONRPCRequest { * * @category `server/discover` */ -export interface DiscoverResult extends Result { +export interface DiscoverResult extends CacheableResult { /** * MCP Protocol Versions this server supports. The client should choose a * version from this list for use in subsequent requests. @@ -674,6 +674,9 @@ export interface ClientCapabilities { * (e.g., "io.modelcontextprotocol/oauth-client-credentials"), and values are * per-extension settings objects. An empty object indicates support with no settings. * + * Keys MUST follow the {@link MetaObject | `_meta` key naming rules}, with a + * mandatory prefix. + * * @example Extensions — MCP Apps (UI) extension with MIME type support * {@includeCode ./examples/ClientCapabilities/extensions-ui-mime-types.json} */ @@ -768,6 +771,9 @@ export interface ServerCapabilities { * (e.g., "io.modelcontextprotocol/tasks"), and values are per-extension settings * objects. An empty object indicates support with no settings. * + * Keys MUST follow the {@link MetaObject | `_meta` key naming rules}, with a + * mandatory prefix. + * * @example Extensions — Tasks extension support * {@includeCode ./examples/ServerCapabilities/extensions-tasks.json} */ @@ -2963,6 +2969,18 @@ export interface ElicitResult { content?: { [key: string]: string | number | boolean | string[] }; } +/** + * Parameters for a {@link ElicitationCompleteNotification | notifications/elicitation/complete} notification. + * + * @category `notifications/elicitation/complete` + */ +export interface ElicitationCompleteNotificationParams extends NotificationParams { + /** + * The ID of the elicitation that completed. + */ + elicitationId: string; +} + /** * An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request. * @@ -2973,12 +2991,7 @@ export interface ElicitResult { */ export interface ElicitationCompleteNotification extends JSONRPCNotification { method: 'notifications/elicitation/complete'; - params: { - /** - * The ID of the elicitation that completed. - */ - elicitationId: string; - }; + params: ElicitationCompleteNotificationParams; } /* Client messages */ diff --git a/packages/core/src/types/specTypeSchema.ts b/packages/core/src/types/specTypeSchema.ts index e538da8fa5..de66e99418 100644 --- a/packages/core/src/types/specTypeSchema.ts +++ b/packages/core/src/types/specTypeSchema.ts @@ -41,8 +41,6 @@ const SPEC_SCHEMA_KEYS = [ 'CallToolResultSchema', 'CancelledNotificationSchema', 'CancelledNotificationParamsSchema', - 'CancelTaskRequestSchema', - 'CancelTaskResultSchema', 'ClientCapabilitiesSchema', 'ClientNotificationSchema', 'ClientRequestSchema', @@ -56,7 +54,6 @@ const SPEC_SCHEMA_KEYS = [ 'CreateMessageRequestParamsSchema', 'CreateMessageResultSchema', 'CreateMessageResultWithToolsSchema', - 'CreateTaskResultSchema', 'CursorSchema', 'DiscoverRequestSchema', 'DiscoverResultSchema', @@ -73,10 +70,6 @@ const SPEC_SCHEMA_KEYS = [ 'GetPromptRequestSchema', 'GetPromptRequestParamsSchema', 'GetPromptResultSchema', - 'GetTaskPayloadRequestSchema', - 'GetTaskPayloadResultSchema', - 'GetTaskRequestSchema', - 'GetTaskResultSchema', 'IconSchema', 'IconsSchema', 'ImageContentSchema', @@ -103,8 +96,6 @@ const SPEC_SCHEMA_KEYS = [ 'ListResourceTemplatesResultSchema', 'ListRootsRequestSchema', 'ListRootsResultSchema', - 'ListTasksRequestSchema', - 'ListTasksResultSchema', 'ListToolsRequestSchema', 'ListToolsResultSchema', 'LoggingLevelSchema', @@ -135,7 +126,6 @@ const SPEC_SCHEMA_KEYS = [ 'RelatedTaskMetadataSchema', 'RequestSchema', 'RequestIdSchema', - 'RequestMetaEnvelopeSchema', 'RequestMetaSchema', 'ResourceSchema', 'ResourceContentsSchema', @@ -163,13 +153,8 @@ const SPEC_SCHEMA_KEYS = [ 'StringSchemaSchema', 'SubscribeRequestSchema', 'SubscribeRequestParamsSchema', - 'TaskSchema', 'TaskAugmentedRequestParamsSchema', - 'TaskCreationParamsSchema', 'TaskMetadataSchema', - 'TaskStatusSchema', - 'TaskStatusNotificationSchema', - 'TaskStatusNotificationParamsSchema', 'TextContentSchema', 'TextResourceContentsSchema', 'TitledMultiSelectEnumSchemaSchema', @@ -223,7 +208,12 @@ export type SpecTypeName = StripSchemaSuffix; /** * Maps each {@linkcode SpecTypeName} to its TypeScript type. * - * `SpecTypes['CallToolResult']` is equivalent to importing the `CallToolResult` type directly. + * `SpecTypes['Tool']` is equivalent to importing the `Tool` type directly. + * These validators cover the NEUTRAL model — the consumer-facing shapes with + * no wire-only members (`resultType`, the reserved `_meta` envelope keys). + * Per-revision WIRE validators are deliberately not public surface; they are + * planned to return as versioned `zod-schemas/` exports for + * consumers who validate raw wire traffic themselves. */ export type SpecTypes = { [K in SchemaKey as StripSchemaSuffix]: SchemaFor extends z.ZodType ? z.output> : never; diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index e0fe28b500..92acc6a6ad 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -4,6 +4,27 @@ import type * as z from 'zod/v4'; +// Wire-module schema imports, TYPE-ONLY (erased at runtime): the deprecated +// task vocabulary and the per-request envelope are wire-era artifacts whose +// schemas live in the codec modules; their inferred TYPES stay importable +// from this neutral layer (Q1-SD2). +import type { + CancelTaskRequestSchema, + CancelTaskResultSchema, + CreateTaskResultSchema, + GetTaskPayloadRequestSchema, + GetTaskPayloadResultSchema, + GetTaskRequestSchema, + GetTaskResultSchema, + ListTasksRequestSchema, + ListTasksResultSchema, + TaskCreationParamsSchema, + TaskSchema, + TaskStatusNotificationParamsSchema, + TaskStatusNotificationSchema, + TaskStatusSchema +} from '../wire/rev2025-11-25/schemas.js'; +import type { RequestMetaEnvelopeSchema } from '../wire/rev2026-07-28/schemas.js'; import type { INTERNAL_ERROR, INVALID_PARAMS, INVALID_REQUEST, METHOD_NOT_FOUND, PARSE_ERROR } from './constants.js'; import type { AnnotationsSchema, @@ -17,8 +38,6 @@ import type { CallToolResultSchema, CancelledNotificationParamsSchema, CancelledNotificationSchema, - CancelTaskRequestSchema, - CancelTaskResultSchema, ClientCapabilitiesSchema, ClientNotificationSchema, ClientRequestSchema, @@ -32,7 +51,6 @@ import type { CreateMessageRequestSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - CreateTaskResultSchema, CursorSchema, DiscoverRequestSchema, DiscoverResultSchema, @@ -49,10 +67,6 @@ import type { GetPromptRequestParamsSchema, GetPromptRequestSchema, GetPromptResultSchema, - GetTaskPayloadRequestSchema, - GetTaskPayloadResultSchema, - GetTaskRequestSchema, - GetTaskResultSchema, IconSchema, IconsSchema, ImageContentSchema, @@ -62,10 +76,8 @@ import type { InitializeRequestSchema, InitializeResultSchema, JSONRPCErrorResponseSchema, - JSONRPCMessageSchema, JSONRPCNotificationSchema, JSONRPCRequestSchema, - JSONRPCResponseSchema, JSONRPCResultResponseSchema, LegacyTitledEnumSchemaSchema, ListPromptsRequestSchema, @@ -76,8 +88,6 @@ import type { ListResourceTemplatesResultSchema, ListRootsRequestSchema, ListRootsResultSchema, - ListTasksRequestSchema, - ListTasksResultSchema, ListToolsRequestSchema, ListToolsResultSchema, LoggingLevelSchema, @@ -108,7 +118,6 @@ import type { ReadResourceResultSchema, RelatedTaskMetadataSchema, RequestIdSchema, - RequestMetaEnvelopeSchema, RequestMetaSchema, RequestSchema, ResourceContentsSchema, @@ -138,12 +147,7 @@ import type { SubscribeRequestParamsSchema, SubscribeRequestSchema, TaskAugmentedRequestParamsSchema, - TaskCreationParamsSchema, TaskMetadataSchema, - TaskSchema, - TaskStatusNotificationParamsSchema, - TaskStatusNotificationSchema, - TaskStatusSchema, TextContentSchema, TextResourceContentsSchema, TitledMultiSelectEnumSchemaSchema, @@ -186,21 +190,41 @@ type Flatten = T extends Primitive type Infer = Flatten>; +/** + * Wire-only members hidden from the public types. + * + * `resultType` is the protocol-revision-2026-07-28 wire discrimination field + * on results. It is consumed by the SDK's protocol layer (and stripped before + * results reach consumers), so the public result types do not declare it. + * The wire schemas continue to model it internally. + */ +type WireOnlyResultKey = 'resultType'; + +/** + * Removes wire-only members from a (possibly union) schema-inferred type + * while preserving every other declared member, optionality, and the loose + * index signature. + */ +type StripWireOnly = T extends unknown ? { [K in keyof T as K extends WireOnlyResultKey ? never : K]: T[K] } : never; + /* JSON-RPC types */ export type ProgressToken = Infer; export type Cursor = Infer; export type Request = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type TaskAugmentedRequestParams = Infer; export type RequestMeta = Infer; export type Notification = Infer; -export type Result = Infer; +export type Result = StripWireOnly>; export type RequestId = Infer; export type JSONRPCRequest = Infer; export type JSONRPCNotification = Infer; -export type JSONRPCResponse = Infer; export type JSONRPCErrorResponse = Infer; -export type JSONRPCResultResponse = Infer; -export type JSONRPCMessage = Infer; +// The response/message envelopes embed result objects, so they are rebuilt +// from the public (wire-only-stripped) `Result` rather than schema-inferred. +export type JSONRPCResultResponse = Omit, 'result'> & { result: Result }; +export type JSONRPCResponse = JSONRPCResultResponse | JSONRPCErrorResponse; +export type JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResultResponse | JSONRPCErrorResponse; export type RequestParams = Infer; export type NotificationParams = Infer; /** @@ -210,7 +234,7 @@ export type NotificationParams = Infer; export type RequestMetaEnvelope = Infer; /* Empty result */ -export type EmptyResult = Infer; +export type EmptyResult = StripWireOnly>; /* Cancellation */ export type CancelledNotificationParams = Infer; @@ -243,12 +267,12 @@ export type InitializeRequest = Infer; * months. See `ServerCapabilitiesSchema`. */ export type ServerCapabilities = Infer; -export type InitializeResult = Infer; +export type InitializeResult = StripWireOnly>; export type InitializedNotification = Infer; /* Discovery */ export type DiscoverRequest = Infer; -export type DiscoverResult = Infer; +export type DiscoverResult = StripWireOnly>; /* Ping */ export type PingRequest = Infer; @@ -258,28 +282,52 @@ export type Progress = Infer; export type ProgressNotificationParams = Infer; export type ProgressNotification = Infer; -/* Tasks */ +/* Tasks + * + * The task wire surface defined by the 2025-11-25 protocol revision. These + * types stay importable as wire vocabulary for interoperability with peers on + * that revision, but they appear in no SDK API signature: the SDK has no task + * runtime, and the typed method maps (RequestMethod/RequestTypeMap/ + * ResultTypeMap/NotificationTypeMap) do not include the task methods. + * Removable at the major version that drops 2025-era support. + */ +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type Task = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type TaskStatus = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type TaskCreationParams = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type TaskMetadata = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type RelatedTaskMetadata = Infer; -export type CreateTaskResult = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ +export type CreateTaskResult = StripWireOnly>; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type TaskStatusNotificationParams = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type TaskStatusNotification = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type GetTaskRequest = Infer; -export type GetTaskResult = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ +export type GetTaskResult = StripWireOnly>; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type GetTaskPayloadRequest = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type ListTasksRequest = Infer; -export type ListTasksResult = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ +export type ListTasksResult = StripWireOnly>; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ export type CancelTaskRequest = Infer; -export type CancelTaskResult = Infer; -export type GetTaskPayloadResult = Infer; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ +export type CancelTaskResult = StripWireOnly>; +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ +export type GetTaskPayloadResult = StripWireOnly>; /* Pagination */ export type PaginatedRequestParams = Infer; export type PaginatedRequest = Infer; -export type PaginatedResult = Infer; +export type PaginatedResult = StripWireOnly>; /* Resources */ export type ResourceContents = Infer; @@ -289,13 +337,13 @@ export type Resource = Infer; // TODO: Overlaps with exported `ResourceTemplate` class from `server`. export type ResourceTemplateType = Infer; export type ListResourcesRequest = Infer; -export type ListResourcesResult = Infer; +export type ListResourcesResult = StripWireOnly>; export type ListResourceTemplatesRequest = Infer; -export type ListResourceTemplatesResult = Infer; +export type ListResourceTemplatesResult = StripWireOnly>; export type ResourceRequestParams = Infer; export type ReadResourceRequestParams = Infer; export type ReadResourceRequest = Infer; -export type ReadResourceResult = Infer; +export type ReadResourceResult = StripWireOnly>; export type ResourceListChangedNotification = Infer; export type SubscribeRequestParams = Infer; export type SubscribeRequest = Infer; @@ -308,7 +356,7 @@ export type ResourceUpdatedNotification = Infer; export type Prompt = Infer; export type ListPromptsRequest = Infer; -export type ListPromptsResult = Infer; +export type ListPromptsResult = StripWireOnly>; export type GetPromptRequestParams = Infer; export type GetPromptRequest = Infer; export type TextContent = Infer; @@ -320,7 +368,7 @@ export type EmbeddedResource = Infer; export type ResourceLink = Infer; export type ContentBlock = Infer; export type PromptMessage = Infer; -export type GetPromptResult = Infer; +export type GetPromptResult = StripWireOnly>; export type PromptListChangedNotification = Infer; /* Tools */ @@ -328,10 +376,10 @@ export type ToolAnnotations = Infer; export type ToolExecution = Infer; export type Tool = Infer; export type ListToolsRequest = Infer; -export type ListToolsResult = Infer; +export type ListToolsResult = StripWireOnly>; export type CallToolRequestParams = Infer; -export type CallToolResult = Infer; -export type CompatibilityCallToolResult = Infer; +export type CallToolResult = StripWireOnly>; +export type CompatibilityCallToolResult = StripWireOnly>; export type CallToolRequest = Infer; export type ToolListChangedNotification = Infer; @@ -351,8 +399,8 @@ export type SamplingMessageContentBlock = Infer; export type CreateMessageRequestParams = Infer; export type CreateMessageRequest = Infer; -export type CreateMessageResult = Infer; -export type CreateMessageResultWithTools = Infer; +export type CreateMessageResult = StripWireOnly>; +export type CreateMessageResultWithTools = StripWireOnly>; /* Elicitation */ export type BooleanSchema = Infer; @@ -373,42 +421,52 @@ export type ElicitRequestURLParams = Infer; export type ElicitRequest = Infer; export type ElicitationCompleteNotificationParams = Infer; export type ElicitationCompleteNotification = Infer; -export type ElicitResult = Infer; +export type ElicitResult = StripWireOnly>; /* Autocomplete */ export type ResourceTemplateReference = Infer; export type PromptReference = Infer; export type CompleteRequestParams = Infer; export type CompleteRequest = Infer; -export type CompleteResult = Infer; +export type CompleteResult = StripWireOnly>; /* Roots */ export type Root = Infer; export type ListRootsRequest = Infer; -export type ListRootsResult = Infer; +export type ListRootsResult = StripWireOnly>; export type RootsListChangedNotification = Infer; /* Client messages */ export type ClientRequest = Infer; export type ClientNotification = Infer; -export type ClientResult = Infer; +export type ClientResult = StripWireOnly>; /* Server messages */ export type ServerRequest = Infer; export type ServerNotification = Infer; -export type ServerResult = Infer; +export type ServerResult = StripWireOnly>; /* Protocol type maps */ type MethodToTypeMap = { [T in U as T extends { method: infer M extends string } ? M : never]: T; }; -export type RequestMethod = ClientRequest['method'] | ServerRequest['method']; -export type NotificationMethod = ClientNotification['method'] | ServerNotification['method']; -export type RequestTypeMap = MethodToTypeMap; -export type NotificationTypeMap = MethodToTypeMap; +/** + * Task methods are 2025-11-25 wire vocabulary with no SDK runtime: the task + * wire types stay importable (see the Tasks section above), but the typed + * method surface — `request()`, `setRequestHandler()`, `ctx.mcpReq.send()` — + * does not offer them. The wire schemas keep parsing task vocabulary for + * interoperability with 2025-11-25 peers. + */ +type TaskRequestMethod = 'tasks/get' | 'tasks/result' | 'tasks/list' | 'tasks/cancel'; +type TaskNotificationMethod = 'notifications/tasks/status'; +export type RequestMethod = Exclude; +export type NotificationMethod = Exclude; +export type RequestTypeMap = MethodToTypeMap>; +export type NotificationTypeMap = MethodToTypeMap>; export type ResultTypeMap = { ping: EmptyResult; initialize: InitializeResult; + 'server/discover': DiscoverResult; 'completion/complete': CompleteResult; 'logging/setLevel': EmptyResult; 'prompts/get': GetPromptResult; @@ -418,15 +476,11 @@ export type ResultTypeMap = { 'resources/read': ReadResourceResult; 'resources/subscribe': EmptyResult; 'resources/unsubscribe': EmptyResult; - 'tools/call': CallToolResult | CreateTaskResult; + 'tools/call': CallToolResult; 'tools/list': ListToolsResult; - 'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools | CreateTaskResult; - 'elicitation/create': ElicitResult | CreateTaskResult; + 'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools; + 'elicitation/create': ElicitResult; 'roots/list': ListRootsResult; - 'tasks/get': GetTaskResult; - 'tasks/result': Result; - 'tasks/list': ListTasksResult; - 'tasks/cancel': CancelTaskResult; }; /** @@ -484,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). @@ -555,6 +622,33 @@ export type ListChangedHandlers = { resources?: ListChangedOptions; }; +/** + * Protocol-era classification of an inbound message. + * + * Populated by transports that classify messages at the edge (e.g. an HTTP + * entry distinguishing 2025-era from 2026-era traffic). The wire era itself + * is connection state (the negotiated protocol version held by the + * `Client`/`Server` instance); the protocol layer validates a classified + * message against that instance era at dispatch — a mismatch is treated as + * an entry/routing error, never a per-message era switch. Unclassified + * traffic is dispatched on the instance era unchanged, except on long-lived + * dual-era channels (e.g. a stdio server that declared dual-era support), + * where the protocol layer's own classification consult classifies each + * message and selects its era per message. + */ +export interface MessageClassification { + /** + * The wire era the message was classified into: `legacy` for the + * 2025-11-25 family of revisions, `modern` for 2026-07-28 and later. + */ + era: 'legacy' | 'modern'; + + /** + * The exact protocol revision, when the classifier derived one. + */ + revision?: string; +} + /** * Extra information about a message. */ @@ -564,6 +658,16 @@ export interface MessageExtraInfo { */ request?: globalThis.Request; + /** + * Protocol-era classification of the message, when the transport + * classified it at the edge. Validated by the protocol layer against the + * instance's negotiated era at dispatch (the edge→instance handoff + * check); an edge classification never selects the era itself. When the + * transport did not classify, the protocol layer's classification consult + * may populate this carrier per message (long-lived dual-era channels). + */ + classification?: MessageClassification; + /** * The authentication information. */ diff --git a/packages/core/src/wire/bootstrap.ts b/packages/core/src/wire/bootstrap.ts new file mode 100644 index 0000000000..5ae43bb39d --- /dev/null +++ b/packages/core/src/wire/bootstrap.ts @@ -0,0 +1,45 @@ +/** + * Static era pins for lifecycle messages on the OUTBOUND path (the + * chicken-and-egg bootstrap): these messages are sent while the instance's + * negotiated protocol version is still unset, and they self-identify their + * era by construction — `initialize`/`notifications/initialized` ARE the + * legacy handshake (`initialize` ⇒ legacy), and `server/discover` exists only + * on the 2026 era. The pins apply only during that pre-negotiation window + * (`Protocol._resolveOutboundCodec` consults them when the negotiated version + * is `undefined`); once a version is negotiated, every send resolves through + * the instance's era. + * + * Scope notes: + * - OUTBOUND ONLY. Inbound era truth is the instance's negotiated protocol + * version (connection state); an edge classification, when present, is + * VALIDATED against that instance era — never used to pick a codec per + * message — so pinning inbound would have nothing to attach to. An + * inbound `server/discover` on a legacy-era instance correctly falls to + * −32601 by registry absence; serving it requires an instance bound to + * the modern era. + * - `ping` is deliberately NOT pinned. A bare `{method: 'ping'}` carries no + * era marker, and pinning it would let a negotiated-modern session emit a + * 2025-only method onto the modern leg (the exact inverse leak registry + * membership exists to prevent). `ping` era-gates like any other method: + * present on the 2025 era, absent from the 2026 era (the modern keepalive + * story is owned by the negotiation milestones). + */ +import type { WireCodec } from './codec.js'; +import { codecForVersion, MODERN_WIRE_REVISION } from './codec.js'; + +export function bootstrapOutboundCodec(method: string): WireCodec | undefined { + switch (method) { + case 'initialize': + case 'notifications/initialized': { + // The legacy handshake, by definition (Q2). + return codecForVersion(undefined); + } + case 'server/discover': { + // The modern discovery exchange, 2026-era only. + return codecForVersion(MODERN_WIRE_REVISION); + } + default: { + return undefined; + } + } +} diff --git a/packages/core/src/wire/codec.ts b/packages/core/src/wire/codec.ts new file mode 100644 index 0000000000..6ce2402cb8 --- /dev/null +++ b/packages/core/src/wire/codec.ts @@ -0,0 +1,205 @@ +/** + * The era-granular wire-codec layer (Q1 increment 2). + * + * The SDK separates a revision-neutral model layer (the public types — no + * `resultType`, no `_meta` envelope keys, no retry fields) from per-revision + * WIRE CODECS that own revision-exact schemas, method registries, and the + * decode (wire → neutral lift) / encode (neutral → wire stamp) transforms. + * The codec is a pure function of the negotiated protocol version, which is + * ordinary connection state on the `Protocol` instance: the client stores it + * when its handshake completes, the server stores it at `_oninitialize` (and + * modern-era server instances get it set at instance binding by the entry). + * There is no side table — era resolution is `codecForVersion()`, with the pre-negotiation window covered by the outbound method + * pins in `bootstrap.ts`. + * + * REQUIRED DISCLOSURE (Q1-SD1, era granularity): "the negotiated version + * determines which types are serialized/deserialized over the wire" cashes + * out as "the negotiated wire ERA determines them". All five legacy protocol + * versions (2024-10-07 … 2025-11-25) share one wire vocabulary and map to the + * single 2025-era codec — exactly how the single schema set already served + * all five — and '2026-07-28' maps to the 2026-era codec. A new codec exists + * only when wire vocabulary actually diverges; intra-era vocabulary is NOT + * keyed by exact version. + * + * Deletions are physical: registry membership is the deletion story. The + * 2026-era registry has no `tasks/*`, `initialize`, `ping`, `logging/setLevel`, + * `resources/(un)subscribe` or server→client wire-request entries, so an + * inbound era-mismatched method falls to −32601 by absence — even when a + * handler is registered — and an outbound one dies locally with a typed + * `SdkError` before anything reaches the transport. The 2025-era registry has + * no `server/discover`/`subscriptions/listen`/MRTR entries, symmetrically. + * + * Custom-handler shadowing policy (both directions): a method that belongs to + * the SPEC-METHOD UNIVERSE — the union of every codec's registry, derived, + * not hand-curated — is ALWAYS era-gated, so a custom handler registered for + * a deleted spec method (e.g. `tasks/get`) serves it only on the era that + * defines it. Methods outside the universe are consumer-owned extension + * methods: they are era-blind and require explicit schemas, exactly as today. + * + * Everything in `wire/` is internal to the bundled, `private: true` core — + * nothing per-revision is public surface, and nothing here may ever be + * exported from `core/public`. + */ +import type * as z from 'zod/v4'; + +import type { SdkError } from '../errors/sdkErrors.js'; +import type { + MessageClassification, + NotificationMethod, + NotificationTypeMap, + RequestMetaEnvelope, + RequestMethod, + RequestTypeMap, + Result, + ResultTypeMap +} from '../types/types.js'; +import { rev2025Codec } from './rev2025-11-25/codec.js'; +import { rev2026Codec } from './rev2026-07-28/codec.js'; + +/** Wire eras with distinct vocabulary. */ +export type WireEra = '2025-11-25' | '2026-07-28'; + +/** + * The modern wire revision literal. Internal only — deliberately NOT a public + * constant (G-D2-4: no public modern-version constant ships before era-aware + * list semantics exist). + */ +export const MODERN_WIRE_REVISION = '2026-07-28'; + +/** + * Wire-only material lifted off an inbound message by the protocol layer + * before dispatch (the V-3 seam): the reserved `_meta` envelope keys and the + * multi-round-trip driver fields. This is the typed driver-material channel + * of the codec contract — handlers never see it; the protocol layer surfaces + * it via `ctx.mcpReq.envelope` / `.inputResponses` / `.requestState`, and the + * MRTR driver (M4.1) consumes the retry fields from here. + */ +export interface LiftedWireMaterial { + // Partial: the lift surfaces whichever reserved keys the message actually + // carried — a peer on an adjacent revision may legally send a subset, and + // envelope requiredness is enforced per request at dispatch time + // (`checkInboundEnvelope`), not by the lift. + envelope?: Partial; + inputResponses?: Record; + requestState?: string; +} + +/** Result decode outcomes — the raw-first discrimination (V-1) lives in `decodeResult`. */ +export type DecodedResult = + | { + kind: 'complete'; + /** The neutral result value: wire-only material consumed/stripped. */ + result: Result; + } + | { + kind: 'input_required'; + /** + * Driver-only material (never consumer-visible). The full + * multi-round-trip driver is M4.1 scope; this seam carries the + * discriminated payload to it. + */ + inputRequests: Record; + requestState?: string; + } + | { kind: 'invalid'; error: SdkError }; + +/** + * The per-era wire codec contract (design C §3, adapted to the live funnel + * layout: the universal wire-only LIFT runs once in the protocol layer for + * every message — spec, custom, and fallback paths alike — and codecs consume + * the lifted material rather than re-implementing the strip per era). + */ +export interface WireCodec { + readonly era: WireEra; + + /** Registry membership — the deletion story (inbound −32601 by absence; outbound typed local error). */ + hasRequestMethod(method: string): boolean; + hasNotificationMethod(method: string): boolean; + + /** + * Era-exact dispatch schemas, resolved at dispatch time (never at + * registration time). The method-literal overloads carry the typed parse + * result for statically known spec methods, so call sites need no type + * assertion; `undefined` means the method has no entry on this era's + * registry. + */ + requestSchema(method: M): z.ZodType | undefined; + requestSchema(method: string): z.ZodType | undefined; + resultSchema(method: M): z.ZodType | undefined; + resultSchema(method: string): z.ZodType | undefined; + notificationSchema(method: M): z.ZodType | undefined; + notificationSchema(method: string): z.ZodType | undefined; + + /** + * Step 1 of result decoding: RAW `resultType` handling BEFORE any schema + * validation (V-1's structural home). Era postures (Q1-SD3): + * - 2026 era: required discriminator — absent ⇒ typed error naming the + * spec violation; `input_required` ⇒ driver payload; unknown ⇒ invalid, + * no retry; `complete` ⇒ consume + lift. + * - 2025 era: `resultType` is foreign vocabulary ⇒ strip-on-lift. + */ + decodeResult(method: string, raw: unknown): DecodedResult; + + /** + * 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 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; + + /** + * Inbound envelope enforcement for era-classified traffic: validates the + * lifted envelope material of a request. Returns an error message when + * the era requires an envelope and it is missing/invalid (→ −32602 at the + * dispatch layer); `undefined` when acceptable. The 2025 era never + * requires an envelope. + */ + checkInboundEnvelope(material: LiftedWireMaterial): string | undefined; +} + +/** + * Era resolution, many-to-one (Q1-SD1): all `SUPPORTED_PROTOCOL_VERSIONS` + * (the five legacy versions) → the 2025-era codec; '2026-07-28' → the + * 2026-era codec; `undefined`/unknown → legacy (the DV-13 default posture — + * hand-constructed instances and unclassified traffic are legacy-era). + * + */ +export function codecForVersion(version: string | undefined): WireCodec { + return version === MODERN_WIRE_REVISION ? rev2026Codec : rev2025Codec; +} + +/** + * The wire era a classification names (Q2 — produced at the transport/entry + * edge or, for long-lived dual-era channels, by the protocol layer's own + * per-message classification consult). For edge classifications the dispatch + * funnel never resolves a codec FROM the classification: era is instance + * state, and the classified message is VALIDATED against it — a mismatch is + * an entry/routing error. Only an unbound dual-era instance selects the + * message's codec from its classification (per-message era). The exact + * `revision` wins over the coarse era flag when both are present. + */ +export function classifiedWireEra(classification: MessageClassification): WireEra { + if (classification.revision !== undefined) return codecForVersion(classification.revision).era; + return classification.era === 'modern' ? rev2026Codec.era : rev2025Codec.era; +} + +/** + * The derived spec-method universe: the union of every codec registry. A + * method in this set is era-gated at dispatch and send time; a method outside + * it is a consumer-owned extension method (era-blind, schema-explicit). + * Derived from the registries — never hand-curated (the LEGACY_ONLY_METHODS + * table class is exactly what registry membership replaces). + */ +export function isSpecRequestMethod(method: string): boolean { + return ALL_CODECS.some(codec => codec.hasRequestMethod(method)); +} + +export function isSpecNotificationMethod(method: string): boolean { + return ALL_CODECS.some(codec => codec.hasNotificationMethod(method)); +} + +const ALL_CODECS: readonly WireCodec[] = [rev2025Codec, rev2026Codec]; diff --git a/packages/core/src/wire/rev2025-11-25/codec.ts b/packages/core/src/wire/rev2025-11-25/codec.ts new file mode 100644 index 0000000000..458379d9cd --- /dev/null +++ b/packages/core/src/wire/rev2025-11-25/codec.ts @@ -0,0 +1,64 @@ +/** + * The 2025-era wire codec: decode/encode ≈ identity. + * + * This codec serves every legacy protocol version (2024-10-07 … 2025-11-25). + * It is BEHAVIOR-FROZEN behind the Q10-L2 byte-identity suite — its schemas + * are today's schemas, its registry is today's method map, and its encode + * path is the identity. + * + * Never-stamp guarantee: `encodeResult` is the identity function. There is no + * stamp code path in this module — a 2025-era response cannot carry + * `resultType`, `ttlMs`, `cacheScope`, or envelope keys because no code here + * can write them, not because a stamping branch is gated off. + * + * One deliberate exception to "no 2026 code path" (Q1-SD3 ii, amending the + * V-2 'no code path at all' design claim): `decodeResult` STRIPS a foreign + * `resultType` key from inbound results before validation (strip-on-lift). + * `resultType` is not 2025 vocabulary — a 2025 peer that sends it is + * misbehaving — and the ruled posture is tolerate-and-drop so the foreign key + * can neither surface to consumers (the neutral types have no slot for it) + * nor leak through the retained loose-object passthrough. This is the ONLY + * 2026-vocabulary code path in the 2025 codec, it exists on the decode side + * only, and it deletes — never reads, maps, or emits — the foreign value. + */ +import type { Result } from '../../types/types.js'; +import type { DecodedResult, LiftedWireMaterial, WireCodec } from '../codec.js'; +import { getNotificationSchema, getRequestSchema, getResultSchema, hasNotificationMethod2025, hasRequestMethod2025 } from './registry.js'; + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** The wire→neutral trust boundary: a decoded 2025-era wire result is adopted as the neutral `Result` here (the module's single deliberate assertion). */ +function toNeutralResult(value: unknown): Result { + return value as Result; +} + +export const rev2025Codec: WireCodec = { + era: '2025-11-25', + + hasRequestMethod: hasRequestMethod2025, + hasNotificationMethod: hasNotificationMethod2025, + + requestSchema: getRequestSchema, + resultSchema: getResultSchema, + notificationSchema: getNotificationSchema, + + decodeResult(_method: string, raw: unknown): DecodedResult { + // Strip-on-lift (Q1-SD3 ii): a foreign `resultType` on the 2025 leg is + // dropped before validation, whatever its value. There is no + // discrimination on this era — `resultType` carries no meaning here. + if (isPlainObject(raw) && 'resultType' in raw) { + const stripped = { ...raw }; + delete stripped['resultType']; + return { kind: 'complete', result: toNeutralResult(stripped) }; + } + return { kind: 'complete', result: toNeutralResult(raw) }; + }, + + // The never-stamp guarantee: identity. No stamp code path exists. + encodeResult: (_method: string, result: Result): Result => result, + + // The 2025 era never requires a per-request envelope. + checkInboundEnvelope: (_material: LiftedWireMaterial): string | undefined => undefined +}; diff --git a/packages/core/src/wire/rev2025-11-25/registry.ts b/packages/core/src/wire/rev2025-11-25/registry.ts new file mode 100644 index 0000000000..e81d1e90c8 --- /dev/null +++ b/packages/core/src/wire/rev2025-11-25/registry.ts @@ -0,0 +1,226 @@ +/** + * The 2025-era method registries — re-homed verbatim from + * `types/schemas.ts` (Q1 increment-2 step 1: mechanical relocation behind the + * codec interface; the registry CONTENT is byte-identical to the pre-split + * maps and is pinned by reference in `test/types/registryPins.test.ts`). + * + * This era serves all five legacy protocol versions (2024-10-07 … + * 2025-11-25), exactly as the single schema set did before the split. It is + * BEHAVIOR-FROZEN behind the Q10-L2 byte-identity suite: the request and + * notification maps carry the full deliberate 2025-11-25 wire vocabulary, + * including the task family (the #2248 wire-interop restore). The RESULT map + * is the runtime/typed ALIGNED map (PR #2293 review): keyed by this era's + * subset of the typed `RequestMethod` set so it cannot drift from the typed + * `ResultTypeMap` — no + * task-result union members and no `tasks/*` entries; a task-capable 2025 + * peer's `CreateTaskResult` answer fails the plain per-method schema as a + * typed invalid-result error, and callers needing task interop pass an + * explicit result schema (see `test/shared/typedMapAlignment.test.ts`). + * + * 2026-only vocabulary (`server/discover`, `subscriptions/listen`, the MRTR + * shells, `resultType`, the `_meta` envelope) has NO entry and NO code path + * here — the inverse-leak guarantee is physical absence, not discipline. + */ +import type * as z from 'zod/v4'; + +import { + CallToolRequestSchema, + CallToolResultSchema, + CancelledNotificationSchema, + CompleteRequestSchema, + CompleteResultSchema, + CreateMessageRequestSchema, + CreateMessageResultWithToolsSchema, + ElicitationCompleteNotificationSchema, + ElicitRequestSchema, + ElicitResultSchema, + EmptyResultSchema, + GetPromptRequestSchema, + GetPromptResultSchema, + InitializedNotificationSchema, + InitializeRequestSchema, + InitializeResultSchema, + ListPromptsRequestSchema, + ListPromptsResultSchema, + ListResourcesRequestSchema, + ListResourcesResultSchema, + ListResourceTemplatesRequestSchema, + ListResourceTemplatesResultSchema, + ListRootsRequestSchema, + ListRootsResultSchema, + ListToolsRequestSchema, + ListToolsResultSchema, + LoggingMessageNotificationSchema, + PingRequestSchema, + ProgressNotificationSchema, + PromptListChangedNotificationSchema, + ReadResourceRequestSchema, + ReadResourceResultSchema, + ResourceListChangedNotificationSchema, + ResourceUpdatedNotificationSchema, + RootsListChangedNotificationSchema, + SetLevelRequestSchema, + SubscribeRequestSchema, + ToolListChangedNotificationSchema, + UnsubscribeRequestSchema +} from '../../types/schemas.js'; +import type { NotificationMethod, NotificationTypeMap, RequestMethod, RequestTypeMap, ResultTypeMap } from '../../types/types.js'; +import type { ClientNotificationSchema, ClientRequestSchema, ServerNotificationSchema, ServerRequestSchema } from './schemas.js'; +import { + CancelTaskRequestSchema, + GetTaskPayloadRequestSchema, + GetTaskRequestSchema, + ListTasksRequestSchema, + TaskStatusNotificationSchema +} from './schemas.js'; + +/* The era's wire vocabulary, derived from the wire role unions in + * `./schemas.ts` (the same unions the registries used to be built from at + * runtime). Keying the maps by these derived unions makes drift a compile + * error in BOTH directions: a union member without a map entry, a map entry + * the unions do not know, and an entry pointing at a different method's + * schema all fail to typecheck. */ +type WireRequest = z.output | z.output; +type WireNotification = z.output | z.output; + +/** Every request method in the 2025-era wire vocabulary (the typed `RequestMethod` surface plus the task family). */ +export type Rev2025RequestMethod = WireRequest['method']; +/** Every notification method in the 2025-era wire vocabulary. */ +export type Rev2025NotificationMethod = WireNotification['method']; + +/** + * The typed-method surface this era serves: the typed `RequestMethod` set + * minus methods whose wire vocabulary does not exist on this era (e.g. + * `server/discover`, which the typed maps carry but only the 2026-era + * registry serves). Deriving the subset from the era's own wire role unions + * keeps the both-direction drift guard: a typed 2025-era method without a map + * entry, or a map entry the era's wire vocabulary does not know, is a compile + * error. + */ +type Rev2025TypedRequestMethod = Extract; + +/* Runtime schema lookup — result schemas by method */ +// Keyed by the era's typed-method subset and valued by +// `z.ZodType` so the runtime map and the typed +// `ResultTypeMap` cannot drift: a missing entry, an extra key, or an entry +// that does not parse to the typed map's result type is a compile error. No +// entry may be looser than the typed map (no task-result union members) and +// no key may fall outside it (no `tasks/*` entries — the task methods are +// 2025-11-25 wire vocabulary with no SDK runtime; callers needing task +// interop pass an explicit schema). +const resultSchemas: { readonly [M in Rev2025TypedRequestMethod]: z.ZodType } = { + ping: EmptyResultSchema, + initialize: InitializeResultSchema, + 'completion/complete': CompleteResultSchema, + 'logging/setLevel': EmptyResultSchema, + 'prompts/get': GetPromptResultSchema, + 'prompts/list': ListPromptsResultSchema, + 'resources/list': ListResourcesResultSchema, + 'resources/templates/list': ListResourceTemplatesResultSchema, + 'resources/read': ReadResourceResultSchema, + 'resources/subscribe': EmptyResultSchema, + 'resources/unsubscribe': EmptyResultSchema, + 'tools/call': CallToolResultSchema, + 'tools/list': ListToolsResultSchema, + 'sampling/createMessage': CreateMessageResultWithToolsSchema, + 'elicitation/create': ElicitResultSchema, + 'roots/list': ListRootsResultSchema +}; + +/* Runtime schema lookup — request and notification schemas by method. + * + * The entries are the SAME schema objects the wire role unions are built + * from (reference identity is pinned by `test/types/registryPins.test.ts`), + * and the key order preserves the pre-split union iteration order so the + * exported method lists are byte-identical to the builder they replace. */ +const requestSchemas: { readonly [M in Rev2025RequestMethod]: z.ZodType> } = { + ping: PingRequestSchema, + initialize: InitializeRequestSchema, + 'completion/complete': CompleteRequestSchema, + 'logging/setLevel': SetLevelRequestSchema, + 'prompts/get': GetPromptRequestSchema, + 'prompts/list': ListPromptsRequestSchema, + 'resources/list': ListResourcesRequestSchema, + 'resources/templates/list': ListResourceTemplatesRequestSchema, + 'resources/read': ReadResourceRequestSchema, + 'resources/subscribe': SubscribeRequestSchema, + 'resources/unsubscribe': UnsubscribeRequestSchema, + 'tools/call': CallToolRequestSchema, + 'tools/list': ListToolsRequestSchema, + 'tasks/get': GetTaskRequestSchema, + 'tasks/result': GetTaskPayloadRequestSchema, + 'tasks/list': ListTasksRequestSchema, + 'tasks/cancel': CancelTaskRequestSchema, + 'sampling/createMessage': CreateMessageRequestSchema, + 'elicitation/create': ElicitRequestSchema, + 'roots/list': ListRootsRequestSchema +}; + +const notificationSchemas: { readonly [M in Rev2025NotificationMethod]: z.ZodType> } = { + 'notifications/cancelled': CancelledNotificationSchema, + 'notifications/progress': ProgressNotificationSchema, + 'notifications/initialized': InitializedNotificationSchema, + 'notifications/roots/list_changed': RootsListChangedNotificationSchema, + 'notifications/tasks/status': TaskStatusNotificationSchema, + 'notifications/message': LoggingMessageNotificationSchema, + 'notifications/resources/updated': ResourceUpdatedNotificationSchema, + 'notifications/resources/list_changed': ResourceListChangedNotificationSchema, + 'notifications/tools/list_changed': ToolListChangedNotificationSchema, + 'notifications/prompts/list_changed': PromptListChangedNotificationSchema, + 'notifications/elicitation/complete': ElicitationCompleteNotificationSchema +}; + +/** The 2025-era request-method set (registry membership = the deletion story). */ +export function hasRequestMethod2025(method: string): method is Rev2025RequestMethod { + return Object.prototype.hasOwnProperty.call(requestSchemas, method); +} + +/** The 2025-era notification-method set. */ +export function hasNotificationMethod2025(method: string): method is Rev2025NotificationMethod { + return Object.prototype.hasOwnProperty.call(notificationSchemas, method); +} + +/** Result-map membership: exactly the era's typed-method subset (no task entries, no 2026-only methods). */ +function hasResultMethod(method: string): method is Rev2025TypedRequestMethod { + return Object.prototype.hasOwnProperty.call(resultSchemas, method); +} + +/** + * Gets the Zod schema for validating results of a given request method. + * Returns `undefined` for non-spec methods and 2026-only methods. + * The typed overload is backed by the map's own typing (`z.ZodType` + * per entry), so callers with a statically known 2025-era method can use the + * parsed value without a type assertion. + */ +export function getResultSchema(method: M): z.ZodType; +export function getResultSchema(method: string): z.ZodType | undefined; +export function getResultSchema(method: string): z.ZodType | undefined { + return hasResultMethod(method) ? resultSchemas[method] : undefined; +} + +/** + * Gets the Zod schema for a given request method. + * Returns `undefined` for non-spec methods and 2026-only methods. + * The typed overload returns a ZodType that parses to `RequestTypeMap[M]`, + * allowing callers to use `schema.parse()` without additional type assertions. + */ +export function getRequestSchema(method: M): z.ZodType; +export function getRequestSchema(method: string): z.ZodType | undefined; +export function getRequestSchema(method: string): z.ZodType | undefined { + return hasRequestMethod2025(method) ? requestSchemas[method] : undefined; +} + +/** + * Gets the Zod schema for a given notification method. + * Returns `undefined` for non-spec methods. + * @see getRequestSchema for the typed-overload contract. + */ +export function getNotificationSchema(method: M): z.ZodType; +export function getNotificationSchema(method: string): z.ZodType | undefined; +export function getNotificationSchema(method: string): z.ZodType | undefined { + return hasNotificationMethod2025(method) ? notificationSchemas[method] : undefined; +} + +/** Registry method lists (for the spec-method universe and the CI registry-diff oracle). */ +export const rev2025RequestMethods: readonly string[] = Object.keys(requestSchemas); +export const rev2025NotificationMethods: readonly string[] = Object.keys(notificationSchemas); diff --git a/packages/core/src/wire/rev2025-11-25/schemas.ts b/packages/core/src/wire/rev2025-11-25/schemas.ts new file mode 100644 index 0000000000..3c62d7f900 --- /dev/null +++ b/packages/core/src/wire/rev2025-11-25/schemas.ts @@ -0,0 +1,326 @@ +/** + * 2025-era wire schemas: the task family (protocol revision 2025-11-25) and + * the era's full wire role unions. + * + * Everything here is 2025-only WIRE vocabulary, physically absent from the + * neutral model layer and from the 2026-era codec (Q1 increment 2 - deletions + * are physical). The task message surface was restored types-only by #2248 + * for interop with task-capable 2025 peers and is parsed ONLY through this + * era's registry; the deprecated Task* TYPES remain importable from the types + * barrel (Q1-SD2: nameability is constant, runtime availability is + * version-keyed) but appear in no API signature. + * + * Shared-tier adjudications (documented deviations from a full relocation; + * each would otherwise change frozen 2025 parse behavior, Q10-L2): + * - `RelatedTaskMetadataSchema` stays in the neutral `RequestMetaSchema`: + * `io.modelcontextprotocol/related-task` is NORMATIVE 2025-11-25 `_meta` + * vocabulary, not a leak, and the wire-only lift deliberately exempts it. + * - `TaskMetadataSchema`/`TaskAugmentedRequestParamsSchema` stay neutral: + * they are the (deprecated) `task` param member composed into the shared + * request-param schemas; removing the declared key would change strip-mode + * parsing for 2025 peers. + * - The `tasks` capability sub-schemas stay on the shared capability + * schemas for the same reason; the 2026-era codec strips `capabilities.tasks` + * on encode instead (Q1-SD3 iii). + */ +import * as z from 'zod/v4'; + +import { + BaseRequestParamsSchema, + CallToolRequestSchema, + CallToolResultSchema, + CancelledNotificationSchema, + ClientNotificationSchema as NeutralClientNotificationSchema, + ClientRequestSchema as NeutralClientRequestSchema, + ClientResultSchema as NeutralClientResultSchema, + CompleteRequestSchema, + CompleteResultSchema, + CreateMessageRequestSchema, + CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, + ElicitationCompleteNotificationSchema, + ElicitRequestSchema, + ElicitResultSchema, + EmptyResultSchema, + GetPromptRequestSchema, + GetPromptResultSchema, + InitializedNotificationSchema, + InitializeRequestSchema, + InitializeResultSchema, + ListPromptsRequestSchema, + ListPromptsResultSchema, + ListResourcesRequestSchema, + ListResourcesResultSchema, + ListResourceTemplatesRequestSchema, + ListResourceTemplatesResultSchema, + ListRootsRequestSchema, + ListRootsResultSchema, + ListToolsRequestSchema, + ListToolsResultSchema, + LoggingMessageNotificationSchema, + NotificationSchema, + NotificationsParamsSchema, + PaginatedRequestSchema, + PaginatedResultSchema, + PingRequestSchema, + ProgressNotificationSchema, + PromptListChangedNotificationSchema, + ReadResourceRequestSchema, + ReadResourceResultSchema, + RequestSchema, + ResourceListChangedNotificationSchema, + ResourceUpdatedNotificationSchema, + ResultSchema, + RootsListChangedNotificationSchema, + SetLevelRequestSchema, + SubscribeRequestSchema, + ToolListChangedNotificationSchema, + UnsubscribeRequestSchema +} from '../../types/schemas.js'; + +/** + * Task creation parameters, used to ask that the server create a task to represent a request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const TaskCreationParamsSchema = z.looseObject({ + /** + * Requested duration in milliseconds to retain task from creation. + */ + ttl: z.number().optional(), + + /** + * Time in milliseconds to wait between task status requests. + */ + pollInterval: z.number().optional() +}); + +/** + * The status of a task. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const TaskStatusSchema = z.enum(['working', 'input_required', 'completed', 'failed', 'cancelled']); + +/* Tasks */ +/** + * A pollable state object associated with a request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const TaskSchema = z.object({ + taskId: z.string(), + status: TaskStatusSchema, + /** + * Time in milliseconds to keep task results available after completion. + * If `null`, the task has unlimited lifetime until manually cleaned up. + */ + ttl: z.union([z.number(), z.null()]), + /** + * ISO 8601 timestamp when the task was created. + */ + createdAt: z.string(), + /** + * ISO 8601 timestamp when the task was last updated. + */ + lastUpdatedAt: z.string(), + pollInterval: z.optional(z.number()), + /** + * Optional diagnostic message for failed tasks or other status information. + */ + statusMessage: z.optional(z.string()) +}); + +/** + * Result returned when a task is created, containing the task data wrapped in a `task` field. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const CreateTaskResultSchema = ResultSchema.extend({ + task: TaskSchema +}); + +/** + * Parameters for task status notification. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const TaskStatusNotificationParamsSchema = NotificationsParamsSchema.merge(TaskSchema); + +/** + * A notification sent when a task's status changes. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const TaskStatusNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/tasks/status'), + params: TaskStatusNotificationParamsSchema +}); + +/** + * A request to get the state of a specific task. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const GetTaskRequestSchema = RequestSchema.extend({ + method: z.literal('tasks/get'), + params: BaseRequestParamsSchema.extend({ + taskId: z.string() + }) +}); + +/** + * The response to a {@linkcode GetTaskRequest | tasks/get} request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const GetTaskResultSchema = ResultSchema.merge(TaskSchema); + +/** + * A request to get the result of a specific task. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const GetTaskPayloadRequestSchema = RequestSchema.extend({ + method: z.literal('tasks/result'), + params: BaseRequestParamsSchema.extend({ + taskId: z.string() + }) +}); + +/** + * The response to a `tasks/result` request. + * The structure matches the result type of the original request. + * For example, a {@linkcode CallToolRequest | tools/call} task would return the `CallToolResult` structure. + * + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const GetTaskPayloadResultSchema = ResultSchema.loose(); + +/** + * A request to list tasks. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const ListTasksRequestSchema = PaginatedRequestSchema.extend({ + method: z.literal('tasks/list') +}); + +/** + * The response to a {@linkcode ListTasksRequest | tasks/list} request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const ListTasksResultSchema = PaginatedResultSchema.extend({ + tasks: z.array(TaskSchema) +}); + +/** + * A request to cancel a specific task. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const CancelTaskRequestSchema = RequestSchema.extend({ + method: z.literal('tasks/cancel'), + params: BaseRequestParamsSchema.extend({ + taskId: z.string() + }) +}); + +/** + * The response to a {@linkcode CancelTaskRequest | tasks/cancel} request. + * + * @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. + */ +export const CancelTaskResultSchema = ResultSchema.merge(TaskSchema); + +/* The 2025-era wire role unions: the neutral message sets PLUS the task + * vocabulary. These are the era-faithful aggregates (what a 2025-11-25 peer + * may legally put on the wire, per role) and the source the era registry is + * built from. Member order preserves the pre-split unions (task members + * last for requests/results; notification members are method-discriminated, + * so ordering is not observable). */ +export const ClientRequestSchema = z.union([ + PingRequestSchema, + InitializeRequestSchema, + CompleteRequestSchema, + SetLevelRequestSchema, + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ReadResourceRequestSchema, + SubscribeRequestSchema, + UnsubscribeRequestSchema, + CallToolRequestSchema, + ListToolsRequestSchema, + GetTaskRequestSchema, + GetTaskPayloadRequestSchema, + ListTasksRequestSchema, + CancelTaskRequestSchema +]); + +export const ClientNotificationSchema = z.union([ + CancelledNotificationSchema, + ProgressNotificationSchema, + InitializedNotificationSchema, + RootsListChangedNotificationSchema, + TaskStatusNotificationSchema +]); + +export const ClientResultSchema = z.union([ + EmptyResultSchema, + CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, + ElicitResultSchema, + ListRootsResultSchema, + GetTaskResultSchema, + ListTasksResultSchema, + CreateTaskResultSchema +]); + +export const ServerRequestSchema = z.union([ + PingRequestSchema, + CreateMessageRequestSchema, + ElicitRequestSchema, + ListRootsRequestSchema, + GetTaskRequestSchema, + GetTaskPayloadRequestSchema, + ListTasksRequestSchema, + CancelTaskRequestSchema +]); + +export const ServerNotificationSchema = z.union([ + CancelledNotificationSchema, + ProgressNotificationSchema, + LoggingMessageNotificationSchema, + ResourceUpdatedNotificationSchema, + ResourceListChangedNotificationSchema, + ToolListChangedNotificationSchema, + PromptListChangedNotificationSchema, + TaskStatusNotificationSchema, + ElicitationCompleteNotificationSchema +]); + +export const ServerResultSchema = z.union([ + EmptyResultSchema, + InitializeResultSchema, + CompleteResultSchema, + GetPromptResultSchema, + ListPromptsResultSchema, + ListResourcesResultSchema, + ListResourceTemplatesResultSchema, + ReadResourceResultSchema, + CallToolResultSchema, + ListToolsResultSchema, + GetTaskResultSchema, + ListTasksResultSchema, + CreateTaskResultSchema +]); + +// Reference the imported neutral aggregates so the relationship is explicit +// for readers and tooling: the wire unions above are strict supersets. +void NeutralClientRequestSchema; +void NeutralClientNotificationSchema; +void NeutralClientResultSchema; diff --git a/packages/core/src/wire/rev2025-11-25/wireTypes.ts b/packages/core/src/wire/rev2025-11-25/wireTypes.ts new file mode 100644 index 0000000000..f1c116ccad --- /dev/null +++ b/packages/core/src/wire/rev2025-11-25/wireTypes.ts @@ -0,0 +1,163 @@ +/** + * 2025-era WIRE-VIEW types: the anchor-exact 2025-11-25 shapes for the names + * whose NEUTRAL public types deliberately follow the 2026-07-28 typing. + * + * This module is the visible home of the shared-tier ADJUDICATIONS that the + * old `@ts-expect-error` affordances used to suppress (Q1 increment 2): each + * override below names a field where the 2025 anchor and the neutral model + * disagree, states which side the neutral model follows, and is pinned both + * ways by the per-revision parity suite (spec.types.2025-11-25.test.ts + * compares THESE types against the frozen anchor exactly — zero affordances). + * + * RUNTIME NOTE (Q10-L2): the 2025-era runtime schemas are BEHAVIOR-FROZEN + * and deliberately stay tolerant-wider than these wire views where the + * neutral typing is wider (e.g. `experimental` values accept any JSONObject + * at parse). These types pin the WIRE-LEVEL shape contract against the + * anchor; they do not narrow runtime acceptance. + * + * Adjudication ledger (neutral follows 2026 unless stated): + * - `Tool.inputSchema`/`outputSchema` property values: 2025 wire `object`; + * neutral follows 2026 (`JSONValue`-capable open schema objects). + * - capability blobs (`experimental`, `sampling`, `elicitation`, `tasks`, + * `logging`, `completions`): 2025 wire `object`; neutral `JSONObject`. + * - `extensions` capability key: 2026-only; absent from the 2025 wire view. + * - `CreateMessageRequestParams.metadata`: 2025 wire `object`; neutral + * `JSONObject`. + * - `PromptArgument.title` / `PromptReference.title`: present on the 2025 + * wire (BaseMetadata); the neutral schemas do not declare it and the + * strip-mode parse drops it (PRE-EXISTING runtime gap, recorded in the + * project baseline-bug log — do not silently change parse behavior here). + */ +import type { + CallToolRequest, + CancelTaskRequest, + ClientCapabilities, + CompleteRequest, + CreateMessageRequest, + CreateMessageRequestParams, + ElicitRequest, + GetPromptRequest, + GetTaskPayloadRequest, + GetTaskRequest, + InitializeRequest, + InitializeRequestParams, + InitializeResult, + ListPromptsRequest, + ListResourcesRequest, + ListResourceTemplatesRequest, + ListRootsRequest, + ListTasksRequest, + ListToolsRequest, + ListToolsResult, + PingRequest, + PromptArgument, + PromptReference, + ReadResourceRequest, + ServerCapabilities, + SetLevelRequest, + SubscribeRequest, + Tool, + UnsubscribeRequest +} from '../../types/types.js'; + +/** The 2025 anchor types blob values as bare `object`. */ +type ObjectMap = { [key: string]: object }; + +/** + * Omit that survives loose (index-signature) source types: the plain `Omit` + * collapses named keys into the index signature (`Pick`), which + * silently weakens the pins. Key-remapping preserves both. + */ +type OmitKnown = { [P in keyof T as P extends K ? never : P]: T[P] }; + +/** 2025 wire shape of tool input/output schemas (property values are `object`). */ +export type Wire2025ToolIOSchema = { + $schema?: string; + type: 'object'; + properties?: ObjectMap; + required?: string[]; +}; + +export type Wire2025Tool = OmitKnown & { + inputSchema: Wire2025ToolIOSchema; + outputSchema?: Wire2025ToolIOSchema; +}; + +export type Wire2025ListToolsResult = OmitKnown & { tools: Wire2025Tool[] }; + +export type Wire2025ClientCapabilities = OmitKnown< + ClientCapabilities, + 'extensions' | 'experimental' | 'sampling' | 'elicitation' | 'tasks' +> & { + experimental?: ObjectMap; + sampling?: { context?: object; tools?: object }; + elicitation?: { form?: object; url?: object }; + tasks?: { + list?: object; + cancel?: object; + requests?: { sampling?: { createMessage?: object }; elicitation?: { create?: object } }; + }; +}; + +export type Wire2025ServerCapabilities = OmitKnown< + ServerCapabilities, + 'extensions' | 'experimental' | 'logging' | 'completions' | 'tasks' +> & { + experimental?: ObjectMap; + logging?: object; + completions?: object; + tasks?: { + list?: object; + cancel?: object; + requests?: { tools?: { call?: object } }; + }; +}; + +export type Wire2025InitializeRequestParams = OmitKnown & { + capabilities: Wire2025ClientCapabilities; +}; + +export type Wire2025InitializeRequest = OmitKnown & { params: Wire2025InitializeRequestParams }; + +export type Wire2025InitializeResult = OmitKnown & { capabilities: Wire2025ServerCapabilities }; + +export type Wire2025CreateMessageRequestParams = OmitKnown & { + metadata?: object; + tools?: Wire2025Tool[]; +}; + +export type Wire2025CreateMessageRequest = OmitKnown & { params: Wire2025CreateMessageRequestParams }; + +/** 2025 wire: `title` is a declared BaseMetadata member (the neutral schemas do not model it — see ledger above). */ +export type Wire2025PromptArgument = PromptArgument & { title?: string }; +export type Wire2025PromptReference = PromptReference & { title?: string }; + +/** The 2025 wire role unions with the adjudicated members substituted. */ +export type Wire2025ClientRequestView = + | PingRequest + | Wire2025InitializeRequest + | CompleteRequest + | SetLevelRequest + | GetPromptRequest + | ListPromptsRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | SubscribeRequest + | UnsubscribeRequest + | CallToolRequest + | ListToolsRequest + | GetTaskRequest + | GetTaskPayloadRequest + | ListTasksRequest + | CancelTaskRequest; + +export type Wire2025ServerRequestView = + | PingRequest + | Wire2025CreateMessageRequest + | ElicitRequest + | ListRootsRequest + | GetTaskRequest + | GetTaskPayloadRequest + | ListTasksRequest + | CancelTaskRequest; diff --git a/packages/core/src/wire/rev2026-07-28/codec.ts b/packages/core/src/wire/rev2026-07-28/codec.ts new file mode 100644 index 0000000000..4410a0a05b --- /dev/null +++ b/packages/core/src/wire/rev2026-07-28/codec.ts @@ -0,0 +1,208 @@ +/** + * The 2026-era wire codec (protocol revision 2026-07-28). + * + * Decode = raw-first `resultType` discrimination (the structural V-1 home: + * 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: 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 — 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 + * the violation. The spec's absent⇒complete bridge is scoped to + * EARLIER-revision servers (spec.types.2026-07-28.ts Result.resultType: + * "Servers implementing this protocol version MUST include this field") + * and is deliberately NOT extended to modern traffic. + * (ii) `input_required` → the driver-seam payload (the multi-round-trip + * 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). + */ +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, + getResultSchema2026, + hasNotificationMethod2026, + hasRequestMethod2026 +} from './registry.js'; +import { + CallToolResultSchema, + CompleteResultSchema, + DiscoverResultSchema, + GetPromptResultSchema, + ListPromptsResultSchema, + ListResourcesResultSchema, + ListResourceTemplatesResultSchema, + ListToolsResultSchema, + ReadResourceResultSchema, + RequestMetaEnvelopeSchema +} from './schemas.js'; + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Strip the known deleted-field set from an outbound result (Q1-SD3 iii). */ +function enforceDeletedFields(method: string, result: Result): Result { + let next: Record = result as Record; + let copied = false; + const copy = () => { + if (!copied) { + next = { ...next }; + copied = true; + } + return next; + }; + + // tools arrays: execution (the taskSupport carrier) is deleted vocabulary. + const tools = (result as { tools?: unknown }).tools; + if (method === 'tools/list' && Array.isArray(tools) && tools.some(tool => isPlainObject(tool) && 'execution' in tool)) { + copy().tools = tools.map(tool => { + if (!isPlainObject(tool) || !('execution' in tool)) return tool; + const rest = { ...tool }; + delete rest['execution']; + return rest; + }); + } + + // capability objects: the `tasks` capability is deleted vocabulary. + const capabilities = (result as { capabilities?: unknown }).capabilities; + if (isPlainObject(capabilities) && 'tasks' in capabilities) { + const rest = { ...capabilities }; + delete rest['tasks']; + copy().capabilities = rest; + } + + return next as Result; +} + +export const rev2026Codec: WireCodec = { + era: '2026-07-28', + + hasRequestMethod: hasRequestMethod2026, + hasNotificationMethod: hasNotificationMethod2026, + + requestSchema: getRequestSchema2026, + resultSchema: getResultSchema2026, + notificationSchema: getNotificationSchema2026, + + decodeResult(method: string, raw: unknown): DecodedResult { + if (!isPlainObject(raw)) { + return { + kind: 'invalid', + error: new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${method}: not an object`, { method }) + }; + } + + // Step 1 — RAW discrimination, before any schema (V-1). + const rawResultType = raw['resultType']; + if (rawResultType === undefined) { + // Q1-SD3 (i): hard error naming the violation. + return { + kind: 'invalid', + error: new SdkError( + SdkErrorCode.InvalidResult, + `Invalid result for ${method}: missing required resultType — servers implementing protocol revision 2026-07-28 ` + + `MUST include it (the absent-means-complete bridge applies only to earlier-revision servers)`, + { method, violation: 'missing-resultType' } + ) + }; + } + if (typeof rawResultType !== 'string') { + return { + kind: 'invalid', + error: new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${method}: non-string resultType`, { + method, + resultType: rawResultType + }) + }; + } + if (rawResultType === 'input_required') { + // The driver seam (#13 consumes this payload). + const inputRequests = raw['inputRequests']; + return { + kind: 'input_required', + inputRequests: isPlainObject(inputRequests) ? inputRequests : {}, + ...(typeof raw['requestState'] === 'string' && { requestState: raw['requestState'] }) + }; + } + if (rawResultType !== 'complete') { + // Unrecognized kind ⇒ invalid, no retry (DQ5). + return { + kind: 'invalid', + error: new SdkError(SdkErrorCode.UnsupportedResultType, `Unsupported result type '${rawResultType}' for ${method}`, { + resultType: rawResultType, + method + }) + }; + } + + // Step 2 — wire-exact parse (registry methods), with resultType present. + // Own-key lookup: `method` is peer-influenced on related-request + // paths, and a prototype-chain hit (e.g. 'constructor') must not + // masquerade as a schema and throw out of the decode hop. + const wireSchema = Object.hasOwn(WIRE_RESULT_SCHEMAS, method) ? WIRE_RESULT_SCHEMAS[method] : undefined; + if (wireSchema !== undefined) { + const parsed = wireSchema.safeParse(raw); + if (!parsed.success) { + return { + kind: 'invalid', + error: new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${method}: ${parsed.error}`, { method }) + }; + } + } + + // Step 3 — lift: the wire discriminator is consumed. + const lifted = { ...raw }; + delete lifted['resultType']; + return { kind: 'complete', result: lifted as Result }; + }, + + encodeResult(method: string, result: Result): 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 { + if (material.envelope === undefined) { + return ( + 'Request is missing the required _meta envelope for protocol revision 2026-07-28 ' + + '(io.modelcontextprotocol/protocolVersion, io.modelcontextprotocol/clientInfo, io.modelcontextprotocol/clientCapabilities)' + ); + } + const parsed = RequestMetaEnvelopeSchema.safeParse(material.envelope); + if (!parsed.success) { + return `Invalid _meta envelope for protocol revision 2026-07-28: ${parsed.error.issues.map(issue => issue.message).join('; ')}`; + } + return undefined; + } +}; + +/** Wire-true result wrappers consulted by decode step 2, keyed by method. */ +const WIRE_RESULT_SCHEMAS: Record = { + 'tools/call': CallToolResultSchema, + 'tools/list': ListToolsResultSchema, + 'prompts/get': GetPromptResultSchema, + 'prompts/list': ListPromptsResultSchema, + 'resources/list': ListResourcesResultSchema, + 'resources/templates/list': ListResourceTemplatesResultSchema, + 'resources/read': ReadResourceResultSchema, + 'completion/complete': CompleteResultSchema, + 'server/discover': DiscoverResultSchema +}; 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..d03613aeeb --- /dev/null +++ b/packages/core/src/wire/rev2026-07-28/encodeContract.ts @@ -0,0 +1,127 @@ +/** + * 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. + * 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. + */ +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/src/wire/rev2026-07-28/registry.ts b/packages/core/src/wire/rev2026-07-28/registry.ts new file mode 100644 index 0000000000..e361e65eff --- /dev/null +++ b/packages/core/src/wire/rev2026-07-28/registry.ts @@ -0,0 +1,84 @@ +/** + * The 2026-era method registries (protocol revision 2026-07-28). + * + * Registry membership IS the deletion story: there are NO entries for + * `initialize`, `notifications/initialized`, `ping`, `logging/setLevel`, + * `resources/subscribe`, `resources/unsubscribe`, + * `notifications/roots/list_changed`, the task family, or the server→client + * wire-request channel — so an era-mismatched method falls to −32601 by + * absence inbound and a typed local error outbound, with no table to forget. + * + * HAND-REGISTRY SEED DECISIONS (pinned by the CI registry-diff oracle, which + * fails LOUD if this list and the anchor diff ever disagree): + * - `sampling/createMessage`, `elicitation/create`, `roots/list`: the anchor + * still carries their method literals on bare interfaces, but 2026 DEMOTES + * them from wire requests to in-band `InputRequest` payloads — the entire + * server→client JSON-RPC request channel is deleted (`ServerRequest` has + * no 2026 export). A generator walking method literals would re-admit them + * (the ATK-D flavor-b trap); this hand registry excludes them by + * construction. Their in-band role lands with the MRTR driver (#13). + * - `subscriptions/listen` + `notifications/subscriptions/acknowledged` + * (SEP-1865): 2026-only vocabulary whose SHELLS land with the + * subscriptions feature (#14). Until then they are absent here — inbound + * listen gets −32601 (capability not yet served), which is protocol-legal + * for a server that does not implement subscriptions. + */ +import type * as z from 'zod/v4'; + +import type { NotificationMethod, NotificationTypeMap, RequestMethod, RequestTypeMap, ResultTypeMap } from '../../types/types.js'; +import type { Rev2026NotificationMethod, Rev2026RequestMethod } from './schemas.js'; +import { dispatchRequestSchemas, dispatchResultSchemas, notificationSchemas2026 } from './schemas.js'; + +/** The 2026-era request-method set (registry membership = the deletion story). */ +export function hasRequestMethod2026(method: string): method is Rev2026RequestMethod { + return Object.prototype.hasOwnProperty.call(dispatchRequestSchemas, method); +} + +/** The 2026-era notification-method set. */ +export function hasNotificationMethod2026(method: string): method is Rev2026NotificationMethod { + return Object.prototype.hasOwnProperty.call(notificationSchemas2026, method); +} + +/** Result-map membership (same key set as the request map on this era). */ +function hasResultMethod2026(method: string): method is Rev2026RequestMethod { + return Object.prototype.hasOwnProperty.call(dispatchResultSchemas, method); +} + +/** + * Gets the dispatch (post-lift) Zod schema for a given request method. + * Returns `undefined` for methods this era's registry does not define. + * The typed overload mirrors `WireCodec.requestSchema` so call sites with a + * statically known method need no type assertion. + */ +export function getRequestSchema2026(method: M): z.ZodType | undefined; +export function getRequestSchema2026(method: string): z.ZodType | undefined; +export function getRequestSchema2026(method: string): z.ZodType | undefined { + return hasRequestMethod2026(method) ? dispatchRequestSchemas[method] : undefined; +} + +/** + * Gets the dispatch (post-lift) Zod schema for validating results of a given + * request method. Returns `undefined` for methods this era's registry does + * not define. + * @see getRequestSchema2026 for the typed-overload contract. + */ +export function getResultSchema2026(method: M): z.ZodType | undefined; +export function getResultSchema2026(method: string): z.ZodType | undefined; +export function getResultSchema2026(method: string): z.ZodType | undefined { + return hasResultMethod2026(method) ? dispatchResultSchemas[method] : undefined; +} + +/** + * Gets the Zod schema for a given notification method. + * Returns `undefined` for methods this era's registry does not define. + * @see getRequestSchema2026 for the typed-overload contract. + */ +export function getNotificationSchema2026(method: M): z.ZodType | undefined; +export function getNotificationSchema2026(method: string): z.ZodType | undefined; +export function getNotificationSchema2026(method: string): z.ZodType | undefined { + return hasNotificationMethod2026(method) ? notificationSchemas2026[method] : undefined; +} + +/** Registry method lists (for the spec-method universe and the CI registry-diff oracle). */ +export const rev2026RequestMethods: readonly string[] = Object.keys(dispatchRequestSchemas); +export const rev2026NotificationMethods: readonly string[] = Object.keys(notificationSchemas2026); diff --git a/packages/core/src/wire/rev2026-07-28/schemas.ts b/packages/core/src/wire/rev2026-07-28/schemas.ts new file mode 100644 index 0000000000..510cd399ef --- /dev/null +++ b/packages/core/src/wire/rev2026-07-28/schemas.ts @@ -0,0 +1,490 @@ +/** + * 2026-era wire schemas (protocol revision 2026-07-28). + * + * This module is the only place the per-request `_meta` envelope is modeled. + * The envelope is wire-only vocabulary: the protocol layer lifts it off + * inbound requests before any handler runs and surfaces it at + * `ctx.mcpReq.envelope`; the 2026-era codec enforces its requiredness at + * dispatch time (`checkInboundEnvelope`) - the former neutral-schema JSDoc + * deferral ("enforced per request at dispatch time, not here") is now + * discharged by that codec step. + * + * No 2025-era traffic ever touches this module, so requiredness here is + * bare and spec-exact (the shared-schema `.catch` hazards do not apply). + */ +import * as z from 'zod/v4'; + +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + LOG_LEVEL_META_KEY, + PROTOCOL_VERSION_META_KEY +} from '../../types/constants.js'; +import { + AnnotationsSchema, + AudioContentSchema, + BaseMetadataSchema, + BlobResourceContentsSchema, + CancelledNotificationSchema, + ClientCapabilitiesSchema, + ContentBlockSchema, + CursorSchema, + ElicitationCompleteNotificationSchema, + IconsSchema, + ImageContentSchema, + ImplementationSchema, + LoggingLevelSchema, + LoggingMessageNotificationSchema, + ProgressNotificationSchema, + ProgressTokenSchema, + PromptListChangedNotificationSchema, + PromptMessageSchema, + PromptReferenceSchema, + PromptSchema, + ResourceContentsSchema, + ResourceListChangedNotificationSchema, + ResourceSchema, + ResourceTemplateReferenceSchema, + ResourceTemplateSchema, + ResourceUpdatedNotificationSchema, + RoleSchema, + ServerCapabilitiesSchema, + TextContentSchema, + TextResourceContentsSchema, + ToolAnnotationsSchema, + ToolListChangedNotificationSchema, + ToolUseContentSchema +} from '../../types/schemas.js'; + +/* 2026-era capability forks (defined ahead of the envelope, which composes + * the client fork). The shared shapes minus the deleted `tasks` key: `tasks` + * is 2025-only vocabulary with no slot on this revision, consistent with the + * encode-side deletion (Q1-SD3 iii). + * + * The client fork lists its members EXPLICITLY (composing the shared member + * schemas by reference) rather than using `.omit()`: the envelope schema + * below reaches the bundled package declarations, and an `.omit()` inference + * is a mapped type whose printed member order is unstable across dts-rollup + * builds (api-report flap). The explicit list doubles as the fork's deletion + * statement — a member added to the shared shape must be re-adjudicated here. */ +const sharedClientCapabilityShape = ClientCapabilitiesSchema.shape; +export const ClientCapabilities2026Schema = z.object({ + experimental: sharedClientCapabilityShape.experimental, + sampling: sharedClientCapabilityShape.sampling, + elicitation: sharedClientCapabilityShape.elicitation, + roots: sharedClientCapabilityShape.roots, + extensions: sharedClientCapabilityShape.extensions +}); +export const ServerCapabilities2026Schema = ServerCapabilitiesSchema.omit({ tasks: true }); + +/* Per-request `_meta` envelope */ +/** + * The per-request `_meta` envelope carried by every request under protocol revision + * 2026-07-28: the protocol version governing the request, the client implementation + * info, and the client's capabilities — declared per request rather than once at + * initialization — plus the optional log-level opt-in. + * + * This schema models the complete envelope on its own (loose: foreign keys + * pass through - the lift extracts exactly the reserved keys, so enforcement + * never sees extension material). Requiredness is enforced per request at + * dispatch time by the 2026-era codec's `checkInboundEnvelope` step. + */ +export const RequestMetaEnvelopeSchema = z.looseObject({ + /** + * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + */ + progressToken: ProgressTokenSchema.optional(), + /** + * The MCP protocol version being used for this request. For the HTTP transport, + * the value must match the `MCP-Protocol-Version` header. + */ + [PROTOCOL_VERSION_META_KEY]: z.string(), + /** + * Identifies the client software making the request. + */ + [CLIENT_INFO_META_KEY]: ImplementationSchema, + /** + * The client's capabilities for this specific request. An empty object means the + * client supports no optional capabilities. Servers must not infer capabilities + * from prior requests. Validated with the 2026 fork: `tasks` has no slot on + * this revision (deleted vocabulary), matching the server-side fork wired + * into `DiscoverResultSchema`. + */ + [CLIENT_CAPABILITIES_META_KEY]: ClientCapabilities2026Schema, + /** + * The desired log level for this request. When absent, the server must not send + * `notifications/message` notifications for the request. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. + */ + [LOG_LEVEL_META_KEY]: LoggingLevelSchema.optional() +}); + +/* ------------------------------------------------------------------------ * + * Forked payload vocabulary (shared-tier admission rule, ATK-B section 1): + * `Tool` and `SamplingMessage` are bidirectionally incomparable between the + * 2025-11-25 and 2026-07-28 anchors, so they FORK per wire module instead of + * sitting in the shared tier. The forks below are 2026-anchor-exact: + * - Tool (2026) has NO `execution` member (ToolExecution and its + * `taskSupport` carrier are deleted vocabulary) — a 2026 peer's tool that + * carries one is stripped on parse, and the encode side strips it from + * outbound tools (Q1-SD3 iii). + * - SamplingMessage (2026) is composed against the 2026 anchor shape. + * ------------------------------------------------------------------------ */ + +/** 2026-era Tool: anchor-exact — no `execution` (deleted vocabulary). */ +export const ToolSchema = z.object({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + description: z.string().optional(), + // Anchor-exact: { $schema?: string; type: 'object'; [key: string]: unknown } + inputSchema: z.looseObject({ + $schema: z.string().optional(), + type: z.literal('object') + }), + // Anchor-exact: { $schema?: string; [key: string]: unknown } + outputSchema: z + .looseObject({ + $schema: z.string().optional() + }) + .optional(), + annotations: ToolAnnotationsSchema.optional(), + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** 2026-era ToolResultContent (anchor-exact: `structuredContent?: unknown`). */ +export const ToolResultContentSchema = z.object({ + type: z.literal('tool_result'), + toolUseId: z.string(), + content: z.array(ContentBlockSchema), + structuredContent: z.unknown().optional(), + isError: z.boolean().optional(), + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** 2026-era sampling content union (composes the forked tool-result shape). */ +export const SamplingMessageContentBlockSchema = z.union([ + TextContentSchema, + ImageContentSchema, + AudioContentSchema, + ToolUseContentSchema, + ToolResultContentSchema +]); + +/** 2026-era SamplingMessage (anchor-exact: single block or array). */ +export const SamplingMessageSchema = z.object({ + role: RoleSchema, + content: z.union([SamplingMessageContentBlockSchema, z.array(SamplingMessageContentBlockSchema)]), + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/* ------------------------------------------------------------------------ * + * Result side. `resultType` is REQUIRED at parse (spec.types.2026-07-28 + * Result.resultType: "Servers implementing this protocol version MUST + * include this field"); requiredness is bare because no 2025-era traffic + * touches this module. These are the WIRE-TRUE artifacts — the corpus and + * the parity suite parse them; `decodeResult` parses with them and then + * LIFTS (drops resultType) to the neutral shape. + * ------------------------------------------------------------------------ */ + +/** Open union per the anchor: 'complete' | 'input_required' | string. */ +export const ResultTypeSchema = z.string(); + +const wireMeta = z.record(z.string(), z.unknown()).optional(); + +function wireResult(shape: T) { + return z.looseObject({ + _meta: wireMeta, + /** REQUIRED on this revision (see module header). */ + resultType: ResultTypeSchema, + ...shape + }); +} + +export const ResultSchema = wireResult({}); + +export const PaginatedResultSchema = wireResult({ + nextCursor: CursorSchema.optional() +}); + +export const CallToolResultSchema = wireResult({ + content: z.array(ContentBlockSchema), + structuredContent: z.unknown().optional(), + isError: z.boolean().optional() +}); + +export const ListToolsResultSchema = wireResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + tools: z.array(ToolSchema), + nextCursor: CursorSchema.optional() +}); + +export const ListPromptsResultSchema = wireResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + prompts: z.array(PromptSchema), + nextCursor: CursorSchema.optional() +}); + +export const GetPromptResultSchema = wireResult({ + description: z.string().optional(), + messages: z.array(PromptMessageSchema) +}); + +export const ListResourcesResultSchema = wireResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + resources: z.array(ResourceSchema), + nextCursor: CursorSchema.optional() +}); + +export const ListResourceTemplatesResultSchema = wireResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + resourceTemplates: z.array(ResourceTemplateSchema), + nextCursor: CursorSchema.optional() +}); + +export const ReadResourceResultSchema = wireResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + contents: z.array(z.union([TextResourceContentsSchema, BlobResourceContentsSchema])) +}); + +export const CompleteResultSchema = wireResult({ + completion: z + .object({ + values: z.array(z.string()).max(100), + total: z.number().int().optional(), + hasMore: z.boolean().optional() + }) + .loose() +}); + +/** CacheableResult (SEP-2549): ttlMs and cacheScope REQUIRED per the anchor. */ +export const CacheableResultSchema = wireResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']) +}); + +export const DiscoverResultSchema = wireResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + supportedVersions: z.array(z.string()), + capabilities: ServerCapabilities2026Schema, + serverInfo: ImplementationSchema, + instructions: z.string().optional() +}); + +/* ------------------------------------------------------------------------ * + * Request side. Two views per method: + * - WIRE-TRUE (`RequestSchema`): params `_meta` carries the REQUIRED + * envelope (anchor RequestParams._meta is required). The corpus and parity + * suite consume these. + * - DISPATCH (post-lift, internal to the registry): the protocol layer's + * universal lift has already extracted the envelope, so dispatch parses a + * 2025-like shape with optional `_meta` (progressToken/extension keys + * only) and NO 2025-only members (`task` is undeclared and strips — + * payload-level deletion is physical on this leg). + * ------------------------------------------------------------------------ */ + +/** Post-lift request `_meta` (progressToken + extension keys; loose). */ +const DispatchRequestMetaSchema = z.looseObject({ + progressToken: ProgressTokenSchema.optional() +}); + +function wireRequest(method: M, paramsShape: T) { + return z.object({ + method: z.literal(method), + params: z.object({ _meta: RequestMetaEnvelopeSchema, ...paramsShape }) + }); +} + +function dispatchRequest(method: M, paramsShape: T) { + return z.object({ + method: z.literal(method), + params: z.object({ _meta: DispatchRequestMetaSchema.optional(), ...paramsShape }).optional() + }); +} + +const callToolParamsShape = { + name: z.string(), + arguments: z.record(z.string(), z.unknown()).optional() +}; +const paginatedParamsShape = { cursor: CursorSchema.optional() }; + +export const CallToolRequestSchema = wireRequest('tools/call', callToolParamsShape); +export const ListToolsRequestSchema = wireRequest('tools/list', paginatedParamsShape); +export const ListPromptsRequestSchema = wireRequest('prompts/list', paginatedParamsShape); +export const GetPromptRequestSchema = wireRequest('prompts/get', { + name: z.string(), + arguments: z.record(z.string(), z.string()).optional() +}); +export const ListResourcesRequestSchema = wireRequest('resources/list', paginatedParamsShape); +export const ListResourceTemplatesRequestSchema = wireRequest('resources/templates/list', paginatedParamsShape); +export const ReadResourceRequestSchema = wireRequest('resources/read', { uri: z.string() }); +const completeParamsShape = { + ref: z.union([PromptReferenceSchema, ResourceTemplateReferenceSchema]), + argument: z.object({ name: z.string(), value: z.string() }), + context: z.object({ arguments: z.record(z.string(), z.string()).optional() }).optional() +}; +export const CompleteRequestSchema = wireRequest('completion/complete', completeParamsShape); +export const DiscoverRequestSchema = wireRequest('server/discover', {}); + +/** + * The 2026-era request-method set — the hand-registry seed (see registry.ts + * for the seed decisions). The dispatch maps below are mapped types over this + * union, so a missing entry, an extra entry, or an entry pointing at another + * method's schema is a compile error; the CI registry-diff oracle pins the + * same set against the anchor at runtime. + */ +export type Rev2026RequestMethod = + | 'tools/call' + | 'tools/list' + | 'prompts/get' + | 'prompts/list' + | 'resources/list' + | 'resources/templates/list' + | 'resources/read' + | 'completion/complete' + | 'server/discover'; + +/** Dispatch (post-lift) request schemas, keyed by method — registry-internal. */ +export const dispatchRequestSchemas: { readonly [M in Rev2026RequestMethod]: z.ZodType<{ method: M }> } = { + 'tools/call': dispatchRequest('tools/call', callToolParamsShape), + 'tools/list': dispatchRequest('tools/list', paginatedParamsShape), + 'prompts/get': dispatchRequest('prompts/get', { + name: z.string(), + arguments: z.record(z.string(), z.string()).optional() + }), + 'prompts/list': dispatchRequest('prompts/list', paginatedParamsShape), + 'resources/list': dispatchRequest('resources/list', paginatedParamsShape), + 'resources/templates/list': dispatchRequest('resources/templates/list', paginatedParamsShape), + 'resources/read': dispatchRequest('resources/read', { uri: z.string() }), + 'completion/complete': dispatchRequest('completion/complete', completeParamsShape), + 'server/discover': dispatchRequest('server/discover', {}) +}; + +/** Dispatch (post-lift) result schemas, keyed by method — what the funnel + * validates AFTER `decodeResult` consumed `resultType`. */ +function liftedResult(shape: T) { + return z.looseObject({ _meta: wireMeta, ...shape }); +} + +export const dispatchResultSchemas: { readonly [M in Rev2026RequestMethod]: z.ZodType } = { + 'tools/call': liftedResult({ + content: z.array(ContentBlockSchema), + structuredContent: z.unknown().optional(), + isError: z.boolean().optional() + }), + 'tools/list': liftedResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + tools: z.array(ToolSchema), + nextCursor: CursorSchema.optional() + }), + 'prompts/get': liftedResult({ + description: z.string().optional(), + messages: z.array(PromptMessageSchema) + }), + 'prompts/list': liftedResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + prompts: z.array(PromptSchema), + nextCursor: CursorSchema.optional() + }), + 'resources/list': liftedResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + resources: z.array(ResourceSchema), + nextCursor: CursorSchema.optional() + }), + 'resources/templates/list': liftedResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + resourceTemplates: z.array(ResourceTemplateSchema), + nextCursor: CursorSchema.optional() + }), + 'resources/read': liftedResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + contents: z.array(z.union([TextResourceContentsSchema, BlobResourceContentsSchema])) + }), + 'completion/complete': liftedResult({ + completion: z + .object({ + values: z.array(z.string()).max(100), + total: z.number().int().optional(), + hasMore: z.boolean().optional() + }) + .loose() + }), + 'server/discover': liftedResult({ + ttlMs: z.number().int().min(0), + cacheScope: z.enum(['public', 'private']), + supportedVersions: z.array(z.string()), + capabilities: ServerCapabilities2026Schema, + serverInfo: ImplementationSchema, + instructions: z.string().optional() + }) +}; + +/* ------------------------------------------------------------------------ * + * Notifications. The 2026 notification set: cancelled, progress, message, + * resources/updated, resources/list_changed, tools/list_changed, + * prompts/list_changed, elicitation/complete. Deleted: initialized, + * roots/list_changed, tasks/status. The shapes are revision-identical to the + * shared schemas, which are composed by reference. (The 2026-only + * subscriptions/acknowledged notification is #14 scope — see registry.ts.) + * ------------------------------------------------------------------------ */ +/** The 2026-era notification-method set (the hand-registry seed; see the deletion list above). */ +export type Rev2026NotificationMethod = + | 'notifications/cancelled' + | 'notifications/progress' + | 'notifications/message' + | 'notifications/resources/updated' + | 'notifications/resources/list_changed' + | 'notifications/tools/list_changed' + | 'notifications/prompts/list_changed' + | 'notifications/elicitation/complete'; + +export const notificationSchemas2026: { readonly [M in Rev2026NotificationMethod]: z.ZodType<{ method: M }> } = { + 'notifications/cancelled': CancelledNotificationSchema, + 'notifications/progress': ProgressNotificationSchema, + 'notifications/message': LoggingMessageNotificationSchema, + 'notifications/resources/updated': ResourceUpdatedNotificationSchema, + 'notifications/resources/list_changed': ResourceListChangedNotificationSchema, + 'notifications/tools/list_changed': ToolListChangedNotificationSchema, + 'notifications/prompts/list_changed': PromptListChangedNotificationSchema, + 'notifications/elicitation/complete': ElicitationCompleteNotificationSchema +}; + +/* ------------------------------------------------------------------------ * + * Response envelopes (wire-true; parity/corpus artifacts). + * ------------------------------------------------------------------------ */ +const wireResultResponse = (result: T) => + z + .object({ + jsonrpc: z.literal('2.0'), + id: z.union([z.string(), z.number().int()]), + result + }) + .strict(); + +export const JSONRPCResultResponseSchema = wireResultResponse(ResultSchema); +export const CallToolResultResponseSchema = wireResultResponse(CallToolResultSchema); +export const ListToolsResultResponseSchema = wireResultResponse(ListToolsResultSchema); +export const ListPromptsResultResponseSchema = wireResultResponse(ListPromptsResultSchema); +export const GetPromptResultResponseSchema = wireResultResponse(GetPromptResultSchema); +export const ListResourcesResultResponseSchema = wireResultResponse(ListResourcesResultSchema); +export const ListResourceTemplatesResultResponseSchema = wireResultResponse(ListResourceTemplatesResultSchema); +export const ReadResourceResultResponseSchema = wireResultResponse(ReadResourceResultSchema); +export const CompleteResultResponseSchema = wireResultResponse(CompleteResultSchema); +export const DiscoverResultResponseSchema = wireResultResponse(DiscoverResultSchema); + +// Referenced by reference to keep the compose-by-reference relationships +// explicit for tooling (these shared payloads serve both eras unchanged). +void AnnotationsSchema; +void ResourceContentsSchema; diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CallToolRequest/call-tool-with-progress-token.json b/packages/core/test/corpus/fixtures/2025-11-25/CallToolRequest/call-tool-with-progress-token.json new file mode 100644 index 0000000000..a19422351c --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CallToolRequest/call-tool-with-progress-token.json @@ -0,0 +1,12 @@ +{ + "method": "tools/call", + "params": { + "name": "get_weather", + "arguments": { + "location": "Berlin" + }, + "_meta": { + "progressToken": 7 + } + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CallToolRequest/call-tool.json b/packages/core/test/corpus/fixtures/2025-11-25/CallToolRequest/call-tool.json new file mode 100644 index 0000000000..a4a986baae --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CallToolRequest/call-tool.json @@ -0,0 +1,9 @@ +{ + "method": "tools/call", + "params": { + "name": "get_weather", + "arguments": { + "location": "New York" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CallToolResult/is-error.json b/packages/core/test/corpus/fixtures/2025-11-25/CallToolResult/is-error.json new file mode 100644 index 0000000000..6d1b416593 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CallToolResult/is-error.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "type": "text", + "text": "Failed to fetch weather data: API rate limit exceeded" + } + ], + "isError": true +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CallToolResult/structured.json b/packages/core/test/corpus/fixtures/2025-11-25/CallToolResult/structured.json new file mode 100644 index 0000000000..6c88b928ab --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CallToolResult/structured.json @@ -0,0 +1,12 @@ +{ + "content": [ + { + "type": "text", + "text": "{\"temperature\": 22.5}" + } + ], + "structuredContent": { + "temperature": 22.5, + "conditions": "Partly cloudy" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CallToolResult/text.json b/packages/core/test/corpus/fixtures/2025-11-25/CallToolResult/text.json new file mode 100644 index 0000000000..1675638535 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CallToolResult/text.json @@ -0,0 +1,8 @@ +{ + "content": [ + { + "type": "text", + "text": "Current weather in New York: 72F, partly cloudy" + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CancelledNotification/cancelled.json b/packages/core/test/corpus/fixtures/2025-11-25/CancelledNotification/cancelled.json new file mode 100644 index 0000000000..ec61e4267b --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CancelledNotification/cancelled.json @@ -0,0 +1,7 @@ +{ + "method": "notifications/cancelled", + "params": { + "requestId": 12, + "reason": "User requested cancellation" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CompleteRequest/complete.json b/packages/core/test/corpus/fixtures/2025-11-25/CompleteRequest/complete.json new file mode 100644 index 0000000000..161168c398 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CompleteRequest/complete.json @@ -0,0 +1,13 @@ +{ + "method": "completion/complete", + "params": { + "ref": { + "type": "ref/prompt", + "name": "code_review" + }, + "argument": { + "name": "language", + "value": "py" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CompleteResult/complete-result.json b/packages/core/test/corpus/fixtures/2025-11-25/CompleteResult/complete-result.json new file mode 100644 index 0000000000..99b8c5a8d7 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CompleteResult/complete-result.json @@ -0,0 +1,7 @@ +{ + "completion": { + "values": ["python", "pytorch", "pyside"], + "total": 10, + "hasMore": true + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CreateMessageRequest/create-message.json b/packages/core/test/corpus/fixtures/2025-11-25/CreateMessageRequest/create-message.json new file mode 100644 index 0000000000..2376b12004 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CreateMessageRequest/create-message.json @@ -0,0 +1,25 @@ +{ + "method": "sampling/createMessage", + "params": { + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "What is the capital of France?" + } + } + ], + "modelPreferences": { + "hints": [ + { + "name": "claude-3-sonnet" + } + ], + "intelligencePriority": 0.8, + "speedPriority": 0.5 + }, + "systemPrompt": "You are a helpful assistant.", + "maxTokens": 100 + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CreateMessageResult/create-message-result.json b/packages/core/test/corpus/fixtures/2025-11-25/CreateMessageResult/create-message-result.json new file mode 100644 index 0000000000..74d3e63b6a --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CreateMessageResult/create-message-result.json @@ -0,0 +1,9 @@ +{ + "role": "assistant", + "content": { + "type": "text", + "text": "The capital of France is Paris." + }, + "model": "claude-3-sonnet-20240307", + "stopReason": "endTurn" +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/CreateTaskResult/create-task.json b/packages/core/test/corpus/fixtures/2025-11-25/CreateTaskResult/create-task.json new file mode 100644 index 0000000000..1cbdac652e --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/CreateTaskResult/create-task.json @@ -0,0 +1,10 @@ +{ + "task": { + "taskId": "786af6b0-2779-48ed-9cc1-b8a8a25b8a86", + "status": "working", + "createdAt": "2025-11-25T10:30:00Z", + "ttl": 60000, + "pollInterval": 5000, + "lastUpdatedAt": "2025-11-25T10:30:05Z" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ElicitRequest/form.json b/packages/core/test/corpus/fixtures/2025-11-25/ElicitRequest/form.json new file mode 100644 index 0000000000..b7c223f106 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ElicitRequest/form.json @@ -0,0 +1,16 @@ +{ + "method": "elicitation/create", + "params": { + "mode": "form", + "message": "Please provide your GitHub username", + "requestedSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ElicitResult/accept.json b/packages/core/test/corpus/fixtures/2025-11-25/ElicitResult/accept.json new file mode 100644 index 0000000000..9b9b00f3a4 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ElicitResult/accept.json @@ -0,0 +1,6 @@ +{ + "action": "accept", + "content": { + "name": "octocat" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/EmptyResult/empty.json b/packages/core/test/corpus/fixtures/2025-11-25/EmptyResult/empty.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/EmptyResult/empty.json @@ -0,0 +1 @@ +{} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/GetPromptRequest/get-prompt.json b/packages/core/test/corpus/fixtures/2025-11-25/GetPromptRequest/get-prompt.json new file mode 100644 index 0000000000..10aef03748 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/GetPromptRequest/get-prompt.json @@ -0,0 +1,9 @@ +{ + "method": "prompts/get", + "params": { + "name": "code_review", + "arguments": { + "code": "def hello():\n print('world')" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/GetPromptResult/get-prompt-result.json b/packages/core/test/corpus/fixtures/2025-11-25/GetPromptResult/get-prompt-result.json new file mode 100644 index 0000000000..fcff6dfbcc --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/GetPromptResult/get-prompt-result.json @@ -0,0 +1,12 @@ +{ + "description": "Code review prompt", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this code:\ndef hello():\n print('world')" + } + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/GetTaskRequest/get-task.json b/packages/core/test/corpus/fixtures/2025-11-25/GetTaskRequest/get-task.json new file mode 100644 index 0000000000..b4bad8297a --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/GetTaskRequest/get-task.json @@ -0,0 +1,6 @@ +{ + "method": "tasks/get", + "params": { + "taskId": "786af6b0-2779-48ed-9cc1-b8a8a25b8a86" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/InitializeRequest/initialize.json b/packages/core/test/corpus/fixtures/2025-11-25/InitializeRequest/initialize.json new file mode 100644 index 0000000000..e4a4ce60e1 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/InitializeRequest/initialize.json @@ -0,0 +1,20 @@ +{ + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": { + "roots": { + "listChanged": true + }, + "sampling": {}, + "elicitation": { + "form": {} + } + }, + "clientInfo": { + "name": "example-client", + "title": "Example Client", + "version": "1.0.0" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/InitializeResult/initialize-result.json b/packages/core/test/corpus/fixtures/2025-11-25/InitializeResult/initialize-result.json new file mode 100644 index 0000000000..61db694725 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/InitializeResult/initialize-result.json @@ -0,0 +1,22 @@ +{ + "protocolVersion": "2025-11-25", + "capabilities": { + "logging": {}, + "prompts": { + "listChanged": true + }, + "resources": { + "subscribe": true, + "listChanged": true + }, + "tools": { + "listChanged": true + } + }, + "serverInfo": { + "name": "example-server", + "title": "Example Server", + "version": "1.0.0" + }, + "instructions": "Optional instructions for the client." +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/InitializedNotification/initialized.json b/packages/core/test/corpus/fixtures/2025-11-25/InitializedNotification/initialized.json new file mode 100644 index 0000000000..de0aae9156 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/InitializedNotification/initialized.json @@ -0,0 +1,3 @@ +{ + "method": "notifications/initialized" +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/JSONRPCErrorResponse/error-envelope.json b/packages/core/test/corpus/fixtures/2025-11-25/JSONRPCErrorResponse/error-envelope.json new file mode 100644 index 0000000000..75b928f98b --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/JSONRPCErrorResponse/error-envelope.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32602, + "message": "Unknown tool: invalid_tool_name" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/JSONRPCRequest/request-envelope.json b/packages/core/test/corpus/fixtures/2025-11-25/JSONRPCRequest/request-envelope.json new file mode 100644 index 0000000000..3c9b8d5943 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/JSONRPCRequest/request-envelope.json @@ -0,0 +1,11 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "get_weather", + "arguments": { + "location": "New York" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/JSONRPCResultResponse/result-envelope.json b/packages/core/test/corpus/fixtures/2025-11-25/JSONRPCResultResponse/result-envelope.json new file mode 100644 index 0000000000..09c1f92fee --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/JSONRPCResultResponse/result-envelope.json @@ -0,0 +1,12 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [ + { + "type": "text", + "text": "72F, partly cloudy" + } + ] + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ListPromptsResult/list-prompts-result.json b/packages/core/test/corpus/fixtures/2025-11-25/ListPromptsResult/list-prompts-result.json new file mode 100644 index 0000000000..478b405ada --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ListPromptsResult/list-prompts-result.json @@ -0,0 +1,16 @@ +{ + "prompts": [ + { + "name": "code_review", + "title": "Request Code Review", + "description": "Asks the LLM to analyze code quality", + "arguments": [ + { + "name": "code", + "description": "The code to review", + "required": true + } + ] + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ListResourceTemplatesResult/list-templates-result.json b/packages/core/test/corpus/fixtures/2025-11-25/ListResourceTemplatesResult/list-templates-result.json new file mode 100644 index 0000000000..6798afa00e --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ListResourceTemplatesResult/list-templates-result.json @@ -0,0 +1,11 @@ +{ + "resourceTemplates": [ + { + "uriTemplate": "file:///{path}", + "name": "Project Files", + "title": "Project Files", + "description": "Access files in the project directory", + "mimeType": "application/octet-stream" + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ListResourcesRequest/list-resources.json b/packages/core/test/corpus/fixtures/2025-11-25/ListResourcesRequest/list-resources.json new file mode 100644 index 0000000000..1114099b54 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ListResourcesRequest/list-resources.json @@ -0,0 +1,4 @@ +{ + "method": "resources/list", + "params": {} +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ListResourcesResult/list-resources-result.json b/packages/core/test/corpus/fixtures/2025-11-25/ListResourcesResult/list-resources-result.json new file mode 100644 index 0000000000..96f8354bf5 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ListResourcesResult/list-resources-result.json @@ -0,0 +1,11 @@ +{ + "resources": [ + { + "uri": "file:///project/src/main.rs", + "name": "main.rs", + "title": "Rust Software Application Main File", + "description": "Primary application entry point", + "mimeType": "text/x-rust" + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ListRootsRequest/list-roots.json b/packages/core/test/corpus/fixtures/2025-11-25/ListRootsRequest/list-roots.json new file mode 100644 index 0000000000..5237f0ba98 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ListRootsRequest/list-roots.json @@ -0,0 +1,3 @@ +{ + "method": "roots/list" +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ListRootsResult/list-roots-result.json b/packages/core/test/corpus/fixtures/2025-11-25/ListRootsResult/list-roots-result.json new file mode 100644 index 0000000000..1fdaed5db4 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ListRootsResult/list-roots-result.json @@ -0,0 +1,8 @@ +{ + "roots": [ + { + "uri": "file:///home/user/projects/myproject", + "name": "My Project" + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ListToolsRequest/list-tools.json b/packages/core/test/corpus/fixtures/2025-11-25/ListToolsRequest/list-tools.json new file mode 100644 index 0000000000..2c264f8727 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ListToolsRequest/list-tools.json @@ -0,0 +1,6 @@ +{ + "method": "tools/list", + "params": { + "cursor": "eyJwYWdlIjogM30=" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ListToolsResult/list-tools-result.json b/packages/core/test/corpus/fixtures/2025-11-25/ListToolsResult/list-tools-result.json new file mode 100644 index 0000000000..cc0eca1eff --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ListToolsResult/list-tools-result.json @@ -0,0 +1,19 @@ +{ + "tools": [ + { + "name": "get_weather", + "title": "Weather Provider", + "description": "Get current weather for a location", + "inputSchema": { + "type": "object", + "properties": { + "location": { + "type": "string" + } + }, + "required": ["location"] + } + } + ], + "nextCursor": "eyJwYWdlIjogNH0=" +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/LoggingMessageNotification/log-message.json b/packages/core/test/corpus/fixtures/2025-11-25/LoggingMessageNotification/log-message.json new file mode 100644 index 0000000000..258aa12575 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/LoggingMessageNotification/log-message.json @@ -0,0 +1,11 @@ +{ + "method": "notifications/message", + "params": { + "level": "error", + "logger": "database", + "data": { + "error": "Connection failed", + "host": "localhost" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/PingRequest/ping.json b/packages/core/test/corpus/fixtures/2025-11-25/PingRequest/ping.json new file mode 100644 index 0000000000..9484af42e3 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/PingRequest/ping.json @@ -0,0 +1,3 @@ +{ + "method": "ping" +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ProgressNotification/progress.json b/packages/core/test/corpus/fixtures/2025-11-25/ProgressNotification/progress.json new file mode 100644 index 0000000000..5c78f7c64f --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ProgressNotification/progress.json @@ -0,0 +1,9 @@ +{ + "method": "notifications/progress", + "params": { + "progressToken": 12, + "progress": 50, + "total": 100, + "message": "Halfway there" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/PromptListChangedNotification/prompt-list-changed.json b/packages/core/test/corpus/fixtures/2025-11-25/PromptListChangedNotification/prompt-list-changed.json new file mode 100644 index 0000000000..ba487a2d5a --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/PromptListChangedNotification/prompt-list-changed.json @@ -0,0 +1,3 @@ +{ + "method": "notifications/prompts/list_changed" +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ReadResourceRequest/read-resource.json b/packages/core/test/corpus/fixtures/2025-11-25/ReadResourceRequest/read-resource.json new file mode 100644 index 0000000000..fcebffa3d1 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ReadResourceRequest/read-resource.json @@ -0,0 +1,6 @@ +{ + "method": "resources/read", + "params": { + "uri": "file:///project/src/main.rs" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ReadResourceResult/blob.json b/packages/core/test/corpus/fixtures/2025-11-25/ReadResourceResult/blob.json new file mode 100644 index 0000000000..527388bde2 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ReadResourceResult/blob.json @@ -0,0 +1,9 @@ +{ + "contents": [ + { + "uri": "file:///project/assets/logo.png", + "mimeType": "image/png", + "blob": "iVBORw0KGgoAAAANSUhEUg==" + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ReadResourceResult/text.json b/packages/core/test/corpus/fixtures/2025-11-25/ReadResourceResult/text.json new file mode 100644 index 0000000000..1396a6bc0a --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ReadResourceResult/text.json @@ -0,0 +1,9 @@ +{ + "contents": [ + { + "uri": "file:///project/src/main.rs", + "mimeType": "text/x-rust", + "text": "fn main() {\n println(\"Hello world\");\n}" + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ResourceListChangedNotification/resource-list-changed.json b/packages/core/test/corpus/fixtures/2025-11-25/ResourceListChangedNotification/resource-list-changed.json new file mode 100644 index 0000000000..5bec1a4c79 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ResourceListChangedNotification/resource-list-changed.json @@ -0,0 +1,3 @@ +{ + "method": "notifications/resources/list_changed" +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ResourceUpdatedNotification/resource-updated.json b/packages/core/test/corpus/fixtures/2025-11-25/ResourceUpdatedNotification/resource-updated.json new file mode 100644 index 0000000000..9f942d4314 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ResourceUpdatedNotification/resource-updated.json @@ -0,0 +1,6 @@ +{ + "method": "notifications/resources/updated", + "params": { + "uri": "file:///project/src/main.rs" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/RootsListChangedNotification/roots-list-changed.json b/packages/core/test/corpus/fixtures/2025-11-25/RootsListChangedNotification/roots-list-changed.json new file mode 100644 index 0000000000..dd94884afb --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/RootsListChangedNotification/roots-list-changed.json @@ -0,0 +1,3 @@ +{ + "method": "notifications/roots/list_changed" +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/SetLevelRequest/set-level.json b/packages/core/test/corpus/fixtures/2025-11-25/SetLevelRequest/set-level.json new file mode 100644 index 0000000000..849853b545 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/SetLevelRequest/set-level.json @@ -0,0 +1,6 @@ +{ + "method": "logging/setLevel", + "params": { + "level": "info" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/SubscribeRequest/subscribe.json b/packages/core/test/corpus/fixtures/2025-11-25/SubscribeRequest/subscribe.json new file mode 100644 index 0000000000..b478078154 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/SubscribeRequest/subscribe.json @@ -0,0 +1,6 @@ +{ + "method": "resources/subscribe", + "params": { + "uri": "file:///project/src/main.rs" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/TaskAugmentedRequestParams/task-augmented-call-params.json b/packages/core/test/corpus/fixtures/2025-11-25/TaskAugmentedRequestParams/task-augmented-call-params.json new file mode 100644 index 0000000000..881f113ff9 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/TaskAugmentedRequestParams/task-augmented-call-params.json @@ -0,0 +1,10 @@ +{ + "task": { + "ttl": 60000 + }, + "_meta": { + "io.modelcontextprotocol/related-task": { + "taskId": "786af6b0-2779-48ed-9cc1-b8a8a25b8a86" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/TaskStatusNotification/task-status.json b/packages/core/test/corpus/fixtures/2025-11-25/TaskStatusNotification/task-status.json new file mode 100644 index 0000000000..170b49bebe --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/TaskStatusNotification/task-status.json @@ -0,0 +1,11 @@ +{ + "method": "notifications/tasks/status", + "params": { + "taskId": "786af6b0-2779-48ed-9cc1-b8a8a25b8a86", + "status": "working", + "statusMessage": "Processing input", + "createdAt": "2025-11-25T10:30:00Z", + "ttl": 60000, + "lastUpdatedAt": "2025-11-25T10:30:05Z" + } +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/ToolListChangedNotification/tool-list-changed.json b/packages/core/test/corpus/fixtures/2025-11-25/ToolListChangedNotification/tool-list-changed.json new file mode 100644 index 0000000000..c9c29c4e10 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/ToolListChangedNotification/tool-list-changed.json @@ -0,0 +1,3 @@ +{ + "method": "notifications/tools/list_changed" +} diff --git a/packages/core/test/corpus/fixtures/2025-11-25/UnsubscribeRequest/unsubscribe.json b/packages/core/test/corpus/fixtures/2025-11-25/UnsubscribeRequest/unsubscribe.json new file mode 100644 index 0000000000..ce9b642f8e --- /dev/null +++ b/packages/core/test/corpus/fixtures/2025-11-25/UnsubscribeRequest/unsubscribe.json @@ -0,0 +1,6 @@ +{ + "method": "resources/unsubscribe", + "params": { + "uri": "file:///project/src/main.rs" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/AudioContent/audio-wav-content.json b/packages/core/test/corpus/fixtures/2026-07-28/AudioContent/audio-wav-content.json new file mode 100644 index 0000000000..1816ec4416 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/AudioContent/audio-wav-content.json @@ -0,0 +1,5 @@ +{ + "type": "audio", + "data": "UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=", + "mimeType": "audio/wav" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/BlobResourceContents/image-file-contents.json b/packages/core/test/corpus/fixtures/2026-07-28/BlobResourceContents/image-file-contents.json new file mode 100644 index 0000000000..5b9ef07c9c --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/BlobResourceContents/image-file-contents.json @@ -0,0 +1,5 @@ +{ + "uri": "file:///example.png", + "mimeType": "image/png", + "blob": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/BooleanSchema/boolean-input-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/BooleanSchema/boolean-input-schema.json new file mode 100644 index 0000000000..48d6d589c1 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/BooleanSchema/boolean-input-schema.json @@ -0,0 +1,6 @@ +{ + "type": "boolean", + "title": "Display Name", + "description": "Description text", + "default": false +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CallToolRequest/call-tool-request.json b/packages/core/test/corpus/fixtures/2026-07-28/CallToolRequest/call-tool-request.json new file mode 100644 index 0000000000..2429aeca86 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CallToolRequest/call-tool-request.json @@ -0,0 +1,19 @@ +{ + "jsonrpc": "2.0", + "id": "call-tool-example", + "method": "tools/call", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }, + "name": "get_weather", + "arguments": { + "location": "New York" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CallToolRequestParams/get-weather-tool-call-params.json b/packages/core/test/corpus/fixtures/2026-07-28/CallToolRequestParams/get-weather-tool-call-params.json new file mode 100644 index 0000000000..c65f9ceae8 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CallToolRequestParams/get-weather-tool-call-params.json @@ -0,0 +1,14 @@ +{ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }, + "name": "get_weather", + "arguments": { + "location": "New York" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CallToolRequestParams/tool-call-params-with-progress-token.json b/packages/core/test/corpus/fixtures/2026-07-28/CallToolRequestParams/tool-call-params-with-progress-token.json new file mode 100644 index 0000000000..8335d11a4e --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CallToolRequestParams/tool-call-params-with-progress-token.json @@ -0,0 +1,15 @@ +{ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {}, + "progressToken": "oivaizmir" + }, + "name": "build_simulation", + "arguments": { + "city": "Micropolis" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/invalid-tool-input-error.json b/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/invalid-tool-input-error.json new file mode 100644 index 0000000000..59648895c2 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/invalid-tool-input-error.json @@ -0,0 +1,10 @@ +{ + "resultType": "complete", + "content": [ + { + "type": "text", + "text": "Invalid departure date: must be in the future. Current date is 08/08/2025." + } + ], + "isError": true +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/result-with-array-structured-content.json b/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/result-with-array-structured-content.json new file mode 100644 index 0000000000..ccb136143b --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/result-with-array-structured-content.json @@ -0,0 +1,13 @@ +{ + "resultType": "complete", + "content": [ + { + "type": "text", + "text": "Found 2 users: Alice (alice@example.com) and Bob (bob@example.com)." + } + ], + "structuredContent": [ + { "id": "1", "name": "Alice", "email": "alice@example.com" }, + { "id": "2", "name": "Bob", "email": "bob@example.com" } + ] +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/result-with-structured-content.json b/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/result-with-structured-content.json new file mode 100644 index 0000000000..b7a9cdb80f --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/result-with-structured-content.json @@ -0,0 +1,14 @@ +{ + "resultType": "complete", + "content": [ + { + "type": "text", + "text": "{\"temperature\": 22.5, \"conditions\": \"Partly cloudy\", \"humidity\": 65}" + } + ], + "structuredContent": { + "temperature": 22.5, + "conditions": "Partly cloudy", + "humidity": 65 + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/result-with-unstructured-text.json b/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/result-with-unstructured-text.json new file mode 100644 index 0000000000..4f54c48d0a --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CallToolResult/result-with-unstructured-text.json @@ -0,0 +1,10 @@ +{ + "resultType": "complete", + "content": [ + { + "type": "text", + "text": "Current weather in New York:\nTemperature: 72°F\nConditions: Partly cloudy" + } + ], + "isError": false +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CallToolResultResponse/call-tool-result-response.json b/packages/core/test/corpus/fixtures/2026-07-28/CallToolResultResponse/call-tool-result-response.json new file mode 100644 index 0000000000..da4c062ca5 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CallToolResultResponse/call-tool-result-response.json @@ -0,0 +1,14 @@ +{ + "jsonrpc": "2.0", + "id": "call-tool-example", + "result": { + "resultType": "complete", + "content": [ + { + "type": "text", + "text": "Current weather in New York:\nTemperature: 72°F\nConditions: Partly cloudy" + } + ], + "isError": false + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CancelledNotification/user-requested-cancellation.json b/packages/core/test/corpus/fixtures/2026-07-28/CancelledNotification/user-requested-cancellation.json new file mode 100644 index 0000000000..aa52f8e4c5 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CancelledNotification/user-requested-cancellation.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": { + "requestId": "123", + "reason": "User requested cancellation" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CancelledNotificationParams/user-requested-cancellation.json b/packages/core/test/corpus/fixtures/2026-07-28/CancelledNotificationParams/user-requested-cancellation.json new file mode 100644 index 0000000000..fb032ac1b4 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CancelledNotificationParams/user-requested-cancellation.json @@ -0,0 +1,4 @@ +{ + "requestId": "123", + "reason": "User requested cancellation" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/elicitation-form-and-url-mode-support.json b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/elicitation-form-and-url-mode-support.json new file mode 100644 index 0000000000..ca75391d3b --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/elicitation-form-and-url-mode-support.json @@ -0,0 +1,6 @@ +{ + "elicitation": { + "form": {}, + "url": {} + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/elicitation-form-only-implicit.json b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/elicitation-form-only-implicit.json new file mode 100644 index 0000000000..29786c4c03 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/elicitation-form-only-implicit.json @@ -0,0 +1,3 @@ +{ + "elicitation": {} +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/extensions-ui-mime-types.json b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/extensions-ui-mime-types.json new file mode 100644 index 0000000000..449bf29f5b --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/extensions-ui-mime-types.json @@ -0,0 +1,7 @@ +{ + "extensions": { + "io.modelcontextprotocol/ui": { + "mimeTypes": ["text/html;profile=mcp-app"] + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/roots-minimum-baseline-support.json b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/roots-minimum-baseline-support.json new file mode 100644 index 0000000000..87a706ee4d --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/roots-minimum-baseline-support.json @@ -0,0 +1,3 @@ +{ + "roots": {} +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/sampling-context-inclusion-support-deprecated.json b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/sampling-context-inclusion-support-deprecated.json new file mode 100644 index 0000000000..f6aba71c7b --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/sampling-context-inclusion-support-deprecated.json @@ -0,0 +1,5 @@ +{ + "sampling": { + "context": {} + } +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/sampling-minimum-baseline-support.json b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/sampling-minimum-baseline-support.json new file mode 100644 index 0000000000..5448e67a33 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/sampling-minimum-baseline-support.json @@ -0,0 +1,3 @@ +{ + "sampling": {} +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/sampling-tool-use-support.json b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/sampling-tool-use-support.json new file mode 100644 index 0000000000..b269d8912c --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ClientCapabilities/sampling-tool-use-support.json @@ -0,0 +1,5 @@ +{ + "sampling": { + "tools": {} + } +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CompleteRequest/completion-request.json b/packages/core/test/corpus/fixtures/2026-07-28/CompleteRequest/completion-request.json new file mode 100644 index 0000000000..f3c5a07417 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CompleteRequest/completion-request.json @@ -0,0 +1,23 @@ +{ + "jsonrpc": "2.0", + "id": "completion-example", + "method": "completion/complete", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }, + "ref": { + "type": "ref/prompt", + "name": "code_review" + }, + "argument": { + "name": "language", + "value": "py" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CompleteRequestParams/prompt-argument-completion-with-context.json b/packages/core/test/corpus/fixtures/2026-07-28/CompleteRequestParams/prompt-argument-completion-with-context.json new file mode 100644 index 0000000000..fb0f637793 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CompleteRequestParams/prompt-argument-completion-with-context.json @@ -0,0 +1,23 @@ +{ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }, + "ref": { + "type": "ref/prompt", + "name": "code_review" + }, + "argument": { + "name": "framework", + "value": "fla" + }, + "context": { + "arguments": { + "language": "python" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CompleteRequestParams/prompt-argument-completion.json b/packages/core/test/corpus/fixtures/2026-07-28/CompleteRequestParams/prompt-argument-completion.json new file mode 100644 index 0000000000..af2bf84a08 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CompleteRequestParams/prompt-argument-completion.json @@ -0,0 +1,18 @@ +{ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }, + "ref": { + "type": "ref/prompt", + "name": "code_review" + }, + "argument": { + "name": "language", + "value": "py" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CompleteResult/multiple-completion-values-with-more-available.json b/packages/core/test/corpus/fixtures/2026-07-28/CompleteResult/multiple-completion-values-with-more-available.json new file mode 100644 index 0000000000..c2f0633562 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CompleteResult/multiple-completion-values-with-more-available.json @@ -0,0 +1,8 @@ +{ + "resultType": "complete", + "completion": { + "values": ["python", "pytorch", "pyside"], + "total": 10, + "hasMore": true + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CompleteResult/single-completion-value.json b/packages/core/test/corpus/fixtures/2026-07-28/CompleteResult/single-completion-value.json new file mode 100644 index 0000000000..36ec8985e5 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CompleteResult/single-completion-value.json @@ -0,0 +1,8 @@ +{ + "resultType": "complete", + "completion": { + "values": ["flask"], + "total": 1, + "hasMore": false + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CompleteResultResponse/completion-result-response.json b/packages/core/test/corpus/fixtures/2026-07-28/CompleteResultResponse/completion-result-response.json new file mode 100644 index 0000000000..fb7156e5fa --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CompleteResultResponse/completion-result-response.json @@ -0,0 +1,12 @@ +{ + "jsonrpc": "2.0", + "id": "completion-example", + "result": { + "resultType": "complete", + "completion": { + "values": ["flask"], + "total": 1, + "hasMore": false + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequest/sampling-request.json b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequest/sampling-request.json new file mode 100644 index 0000000000..70a17485f8 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequest/sampling-request.json @@ -0,0 +1,25 @@ +{ + "method": "sampling/createMessage", + "params": { + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "What is the capital of France?" + } + } + ], + "modelPreferences": { + "hints": [ + { + "name": "claude-3-sonnet" + } + ], + "intelligencePriority": 0.8, + "speedPriority": 0.5 + }, + "systemPrompt": "You are a helpful assistant.", + "maxTokens": 100 + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequestParams/basic-request.json b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequestParams/basic-request.json new file mode 100644 index 0000000000..1c88a3f35f --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequestParams/basic-request.json @@ -0,0 +1,22 @@ +{ + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "What is the capital of France?" + } + } + ], + "modelPreferences": { + "hints": [ + { + "name": "claude-3-sonnet" + } + ], + "intelligencePriority": 0.8, + "speedPriority": 0.5 + }, + "systemPrompt": "You are a helpful assistant.", + "maxTokens": 100 +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequestParams/follow-up-with-tool-results.json b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequestParams/follow-up-with-tool-results.json new file mode 100644 index 0000000000..cdea2d858d --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequestParams/follow-up-with-tool-results.json @@ -0,0 +1,67 @@ +{ + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "What's the weather like in Paris and London?" + } + }, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "call_abc123", + "name": "get_weather", + "input": { "city": "Paris" } + }, + { + "type": "tool_use", + "id": "call_def456", + "name": "get_weather", + "input": { "city": "London" } + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "toolUseId": "call_abc123", + "content": [ + { + "type": "text", + "text": "Weather in Paris: 18°C, partly cloudy" + } + ] + }, + { + "type": "tool_result", + "toolUseId": "call_def456", + "content": [ + { + "type": "text", + "text": "Weather in London: 15°C, rainy" + } + ] + } + ] + } + ], + "tools": [ + { + "name": "get_weather", + "description": "Get current weather for a city", + "inputSchema": { + "type": "object", + "properties": { + "city": { "type": "string" } + }, + "required": ["city"] + } + } + ], + "maxTokens": 1000 +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequestParams/request-with-tools.json b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequestParams/request-with-tools.json new file mode 100644 index 0000000000..f79fd26ac9 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageRequestParams/request-with-tools.json @@ -0,0 +1,31 @@ +{ + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "What's the weather like in Paris and London?" + } + } + ], + "tools": [ + { + "name": "get_weather", + "description": "Get current weather for a city", + "inputSchema": { + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "City name" + } + }, + "required": ["city"] + } + } + ], + "toolChoice": { + "mode": "auto" + }, + "maxTokens": 1000 +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageResult/final-response.json b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageResult/final-response.json new file mode 100644 index 0000000000..a9a457a8a8 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageResult/final-response.json @@ -0,0 +1,9 @@ +{ + "role": "assistant", + "content": { + "type": "text", + "text": "Based on the current weather data:\n\n- **Paris**: 18°C and partly cloudy - quite pleasant!\n- **London**: 15°C and rainy - you'll want an umbrella.\n\nParis has slightly warmer and drier conditions today." + }, + "model": "claude-3-sonnet-20240307", + "stopReason": "endTurn" +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageResult/text-response.json b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageResult/text-response.json new file mode 100644 index 0000000000..3b6f18dc7b --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageResult/text-response.json @@ -0,0 +1,9 @@ +{ + "role": "assistant", + "content": { + "type": "text", + "text": "The capital of France is Paris." + }, + "model": "claude-3-sonnet-20240307", + "stopReason": "endTurn" +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageResult/tool-use-response.json b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageResult/tool-use-response.json new file mode 100644 index 0000000000..7599eee178 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/CreateMessageResult/tool-use-response.json @@ -0,0 +1,23 @@ +{ + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "call_abc123", + "name": "get_weather", + "input": { + "city": "Paris" + } + }, + { + "type": "tool_use", + "id": "call_def456", + "name": "get_weather", + "input": { + "city": "London" + } + } + ], + "model": "claude-3-sonnet-20240307", + "stopReason": "toolUse" +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/DiscoverRequest/server-discover-request.json b/packages/core/test/corpus/fixtures/2026-07-28/DiscoverRequest/server-discover-request.json new file mode 100644 index 0000000000..85c7fe80f1 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/DiscoverRequest/server-discover-request.json @@ -0,0 +1,15 @@ +{ + "jsonrpc": "2.0", + "id": "discover-1", + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/DiscoverResult/server-capabilities-discovery.json b/packages/core/test/corpus/fixtures/2026-07-28/DiscoverResult/server-capabilities-discovery.json new file mode 100644 index 0000000000..9f636318c4 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/DiscoverResult/server-capabilities-discovery.json @@ -0,0 +1,15 @@ +{ + "resultType": "complete", + "supportedVersions": ["2026-07-28"], + "capabilities": { + "tools": {}, + "resources": {} + }, + "serverInfo": { + "name": "ExampleServer", + "version": "1.0.0" + }, + "instructions": "This server provides weather and resource utilities. Prefer `get_weather` for forecast lookups.", + "ttlMs": 3600000, + "cacheScope": "public" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/DiscoverResultResponse/discover-result-response.json b/packages/core/test/corpus/fixtures/2026-07-28/DiscoverResultResponse/discover-result-response.json new file mode 100644 index 0000000000..1a162891bb --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/DiscoverResultResponse/discover-result-response.json @@ -0,0 +1,18 @@ +{ + "jsonrpc": "2.0", + "id": "discover-1", + "result": { + "resultType": "complete", + "supportedVersions": ["2026-07-28"], + "capabilities": { + "tools": {}, + "resources": {} + }, + "serverInfo": { + "name": "ExampleServer", + "version": "1.0.0" + }, + "ttlMs": 3600000, + "cacheScope": "public" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequest/elicitation-request.json b/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequest/elicitation-request.json new file mode 100644 index 0000000000..7c356f3556 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequest/elicitation-request.json @@ -0,0 +1,18 @@ +{ + "method": "elicitation/create", + "params": { + "mode": "form", + "message": "Please provide your GitHub username", + "requestedSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "GitHub Username", + "description": "Your GitHub username" + } + }, + "required": ["name"] + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestFormParams/elicit-multiple-fields.json b/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestFormParams/elicit-multiple-fields.json new file mode 100644 index 0000000000..7b8e0557c6 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestFormParams/elicit-multiple-fields.json @@ -0,0 +1,24 @@ +{ + "mode": "form", + "message": "Please provide your contact information", + "requestedSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Your full name" + }, + "email": { + "type": "string", + "format": "email", + "description": "Your email address" + }, + "age": { + "type": "number", + "minimum": 18, + "description": "Your age" + } + }, + "required": ["name", "email"] + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestFormParams/elicit-single-field.json b/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestFormParams/elicit-single-field.json new file mode 100644 index 0000000000..ea8fb43f64 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestFormParams/elicit-single-field.json @@ -0,0 +1,13 @@ +{ + "mode": "form", + "message": "Please provide your GitHub username", + "requestedSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestURLParams/elicit-sensitive-data.json b/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestURLParams/elicit-sensitive-data.json new file mode 100644 index 0000000000..cf791ee3fe --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestURLParams/elicit-sensitive-data.json @@ -0,0 +1,6 @@ +{ + "mode": "url", + "elicitationId": "550e8400-e29b-41d4-a716-446655440000", + "url": "https://mcp.example.com/ui/set_api_key", + "message": "Please provide your API key to continue." +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ElicitResult/accept-url-mode-no-content.json b/packages/core/test/corpus/fixtures/2026-07-28/ElicitResult/accept-url-mode-no-content.json new file mode 100644 index 0000000000..ab47af78f3 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ElicitResult/accept-url-mode-no-content.json @@ -0,0 +1,3 @@ +{ + "action": "accept" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ElicitResult/input-multiple-fields.json b/packages/core/test/corpus/fixtures/2026-07-28/ElicitResult/input-multiple-fields.json new file mode 100644 index 0000000000..99b18e1990 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ElicitResult/input-multiple-fields.json @@ -0,0 +1,8 @@ +{ + "action": "accept", + "content": { + "name": "Monalisa Octocat", + "email": "octocat@github.com", + "age": 30 + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ElicitResult/input-single-field.json b/packages/core/test/corpus/fixtures/2026-07-28/ElicitResult/input-single-field.json new file mode 100644 index 0000000000..4798da663e --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ElicitResult/input-single-field.json @@ -0,0 +1,6 @@ +{ + "action": "accept", + "content": { + "name": "octocat" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ElicitationCompleteNotification/elicitation-complete.json b/packages/core/test/corpus/fixtures/2026-07-28/ElicitationCompleteNotification/elicitation-complete.json new file mode 100644 index 0000000000..bb6d564585 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ElicitationCompleteNotification/elicitation-complete.json @@ -0,0 +1,7 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/elicitation/complete", + "params": { + "elicitationId": "550e8400-e29b-41d4-a716-446655440000" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/EmbeddedResource/embedded-file-resource-with-annotations.json b/packages/core/test/corpus/fixtures/2026-07-28/EmbeddedResource/embedded-file-resource-with-annotations.json new file mode 100644 index 0000000000..01a8f2eb9f --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/EmbeddedResource/embedded-file-resource-with-annotations.json @@ -0,0 +1,13 @@ +{ + "type": "resource", + "resource": { + "uri": "file:///project/src/main.rs", + "mimeType": "text/x-rust", + "text": "fn main() {\n println!(\"Hello world!\");\n}" + }, + "annotations": { + "audience": ["user", "assistant"], + "priority": 0.7, + "lastModified": "2025-05-03T14:30:00Z" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/GetPromptRequest/get-prompt-request.json b/packages/core/test/corpus/fixtures/2026-07-28/GetPromptRequest/get-prompt-request.json new file mode 100644 index 0000000000..b8af17c98d --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/GetPromptRequest/get-prompt-request.json @@ -0,0 +1,19 @@ +{ + "jsonrpc": "2.0", + "id": "get-prompt-example", + "method": "prompts/get", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }, + "name": "code_review", + "arguments": { + "code": "def hello():\n print('world')" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/GetPromptRequestParams/get-code-review-prompt.json b/packages/core/test/corpus/fixtures/2026-07-28/GetPromptRequestParams/get-code-review-prompt.json new file mode 100644 index 0000000000..0bfdf3b01b --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/GetPromptRequestParams/get-code-review-prompt.json @@ -0,0 +1,14 @@ +{ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }, + "name": "code_review", + "arguments": { + "code": "def hello():\n print('world')" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/GetPromptResult/code-review-prompt.json b/packages/core/test/corpus/fixtures/2026-07-28/GetPromptResult/code-review-prompt.json new file mode 100644 index 0000000000..cb3518aee4 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/GetPromptResult/code-review-prompt.json @@ -0,0 +1,13 @@ +{ + "resultType": "complete", + "description": "Code review prompt", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this Python code:\ndef hello():\n print('world')" + } + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/GetPromptResultResponse/get-prompt-result-response.json b/packages/core/test/corpus/fixtures/2026-07-28/GetPromptResultResponse/get-prompt-result-response.json new file mode 100644 index 0000000000..a257ccf9ed --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/GetPromptResultResponse/get-prompt-result-response.json @@ -0,0 +1,17 @@ +{ + "jsonrpc": "2.0", + "id": "get-prompt-example", + "result": { + "resultType": "complete", + "description": "Code review prompt", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this Python code:\ndef hello():\n print('world')" + } + } + ] + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ImageContent/image-png-content-with-annotations.json b/packages/core/test/corpus/fixtures/2026-07-28/ImageContent/image-png-content-with-annotations.json new file mode 100644 index 0000000000..32f8ef683e --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ImageContent/image-png-content-with-annotations.json @@ -0,0 +1,9 @@ +{ + "type": "image", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "mimeType": "image/png", + "annotations": { + "audience": ["user"], + "priority": 0.9 + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/InputRequests/elicitation-and-sampling-input-requests.json b/packages/core/test/corpus/fixtures/2026-07-28/InputRequests/elicitation-and-sampling-input-requests.json new file mode 100644 index 0000000000..5d1ce974aa --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/InputRequests/elicitation-and-sampling-input-requests.json @@ -0,0 +1,33 @@ +{ + "github_login": { + "method": "elicitation/create", + "params": { + "mode": "form", + "message": "Please provide your GitHub username", + "requestedSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + } + }, + "capital_of_france": { + "method": "sampling/createMessage", + "params": { + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "What is the capital of France?" + } + } + ], + "maxTokens": 100 + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/InputRequiredResult/input-required-result-with-elicitation-and-sampling-and-request-state.json b/packages/core/test/corpus/fixtures/2026-07-28/InputRequiredResult/input-required-result-with-elicitation-and-sampling-and-request-state.json new file mode 100644 index 0000000000..6ffc953944 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/InputRequiredResult/input-required-result-with-elicitation-and-sampling-and-request-state.json @@ -0,0 +1,36 @@ +{ + "resultType": "input_required", + "inputRequests": { + "github_login": { + "method": "elicitation/create", + "params": { + "message": "Please provide your GitHub username", + "requestedSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + } + }, + "capital_of_france": { + "method": "sampling/createMessage", + "params": { + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "What is the capital of France?" + } + } + ], + "maxTokens": 100 + } + } + }, + "requestState": "eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/InputRequiredResult/input-required-result-with-request-state-only.json b/packages/core/test/corpus/fixtures/2026-07-28/InputRequiredResult/input-required-result-with-request-state-only.json new file mode 100644 index 0000000000..7f1dec1f69 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/InputRequiredResult/input-required-result-with-request-state-only.json @@ -0,0 +1,4 @@ +{ + "resultType": "input_required", + "requestState": "eyJwcm9ncmVzcyI6IjUwJSIsInN0YXRlIjoicHJvY2Vzc2luZyJ9" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/InputResponses/elicitation-and-sampling-input-responses.json b/packages/core/test/corpus/fixtures/2026-07-28/InputResponses/elicitation-and-sampling-input-responses.json new file mode 100644 index 0000000000..1f5cbcf0d6 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/InputResponses/elicitation-and-sampling-input-responses.json @@ -0,0 +1,17 @@ +{ + "github_login": { + "action": "accept", + "content": { + "name": "octocat" + } + }, + "capital_of_france": { + "role": "assistant", + "content": { + "type": "text", + "text": "The capital of France is Paris." + }, + "model": "claude-3-sonnet-20240307", + "stopReason": "endTurn" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/InternalError/unexpected-error.json b/packages/core/test/corpus/fixtures/2026-07-28/InternalError/unexpected-error.json new file mode 100644 index 0000000000..2560c88d32 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/InternalError/unexpected-error.json @@ -0,0 +1,4 @@ +{ + "code": -32603, + "message": "Internal error" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/invalid-cursor.json b/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/invalid-cursor.json new file mode 100644 index 0000000000..674bb5422d --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/invalid-cursor.json @@ -0,0 +1,4 @@ +{ + "code": -32602, + "message": "Invalid cursor" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/invalid-tool-arguments.json b/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/invalid-tool-arguments.json new file mode 100644 index 0000000000..afa93c8b4a --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/invalid-tool-arguments.json @@ -0,0 +1,4 @@ +{ + "code": -32602, + "message": "Invalid arguments for tool calculate: Missing required property 'expression'" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/unknown-prompt.json b/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/unknown-prompt.json new file mode 100644 index 0000000000..741e88b0d7 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/unknown-prompt.json @@ -0,0 +1,4 @@ +{ + "code": -32602, + "message": "Unknown prompt: invalid_prompt_name" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/unknown-tool.json b/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/unknown-tool.json new file mode 100644 index 0000000000..bde98fa520 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/InvalidParamsError/unknown-tool.json @@ -0,0 +1,4 @@ +{ + "code": -32602, + "message": "Unknown tool: invalid_tool_name" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListPromptsRequest/list-prompts-request.json b/packages/core/test/corpus/fixtures/2026-07-28/ListPromptsRequest/list-prompts-request.json new file mode 100644 index 0000000000..9fa881f332 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListPromptsRequest/list-prompts-request.json @@ -0,0 +1,15 @@ +{ + "jsonrpc": "2.0", + "id": "list-prompts-example", + "method": "prompts/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListPromptsResult/prompts-list-with-cursor-and-ttl.json b/packages/core/test/corpus/fixtures/2026-07-28/ListPromptsResult/prompts-list-with-cursor-and-ttl.json new file mode 100644 index 0000000000..1d841baa83 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListPromptsResult/prompts-list-with-cursor-and-ttl.json @@ -0,0 +1,27 @@ +{ + "resultType": "complete", + "prompts": [ + { + "name": "code_review", + "title": "Request Code Review", + "description": "Asks the LLM to analyze code quality and suggest improvements", + "arguments": [ + { + "name": "code", + "description": "The code to review", + "required": true + } + ], + "icons": [ + { + "src": "https://example.com/review-icon.svg", + "mimeType": "image/svg+xml", + "sizes": ["any"] + } + ] + } + ], + "nextCursor": "next-page-cursor", + "ttlMs": 600000, + "cacheScope": "public" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListPromptsResultResponse/list-prompts-result-response.json b/packages/core/test/corpus/fixtures/2026-07-28/ListPromptsResultResponse/list-prompts-result-response.json new file mode 100644 index 0000000000..61118cab64 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListPromptsResultResponse/list-prompts-result-response.json @@ -0,0 +1,31 @@ +{ + "jsonrpc": "2.0", + "id": "list-prompts-example", + "result": { + "resultType": "complete", + "prompts": [ + { + "name": "code_review", + "title": "Request Code Review", + "description": "Asks the LLM to analyze code quality and suggest improvements", + "arguments": [ + { + "name": "code", + "description": "The code to review", + "required": true + } + ], + "icons": [ + { + "src": "https://example.com/review-icon.svg", + "mimeType": "image/svg+xml", + "sizes": ["any"] + } + ] + } + ], + "nextCursor": "next-page-cursor", + "ttlMs": 600000, + "cacheScope": "public" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListResourceTemplatesRequest/list-resource-templates-request.json b/packages/core/test/corpus/fixtures/2026-07-28/ListResourceTemplatesRequest/list-resource-templates-request.json new file mode 100644 index 0000000000..13917c5911 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListResourceTemplatesRequest/list-resource-templates-request.json @@ -0,0 +1,15 @@ +{ + "jsonrpc": "2.0", + "id": "list-resource-templates-example", + "method": "resources/templates/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListResourceTemplatesResult/resource-templates-list-with-cursor-and-ttl.json b/packages/core/test/corpus/fixtures/2026-07-28/ListResourceTemplatesResult/resource-templates-list-with-cursor-and-ttl.json new file mode 100644 index 0000000000..7abd62b150 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListResourceTemplatesResult/resource-templates-list-with-cursor-and-ttl.json @@ -0,0 +1,22 @@ +{ + "resultType": "complete", + "resourceTemplates": [ + { + "uriTemplate": "file:///{path}", + "name": "Project Files", + "title": "📁 Project Files", + "description": "Access files in the project directory", + "mimeType": "application/octet-stream", + "icons": [ + { + "src": "https://example.com/folder-icon.png", + "mimeType": "image/png", + "sizes": ["48x48"] + } + ] + } + ], + "nextCursor": "next-page-cursor", + "ttlMs": 3600000, + "cacheScope": "public" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListResourceTemplatesResultResponse/list-resource-templates-result-response.json b/packages/core/test/corpus/fixtures/2026-07-28/ListResourceTemplatesResultResponse/list-resource-templates-result-response.json new file mode 100644 index 0000000000..3fda957c35 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListResourceTemplatesResultResponse/list-resource-templates-result-response.json @@ -0,0 +1,25 @@ +{ + "jsonrpc": "2.0", + "id": "list-resource-templates-example", + "result": { + "resultType": "complete", + "resourceTemplates": [ + { + "uriTemplate": "file:///{path}", + "name": "Project Files", + "title": "Project Files", + "description": "Access files in the project directory", + "mimeType": "application/octet-stream", + "icons": [ + { + "src": "https://example.com/folder-icon.png", + "mimeType": "image/png", + "sizes": ["48x48"] + } + ] + } + ], + "ttlMs": 3600000, + "cacheScope": "public" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListResourcesRequest/list-resources-request.json b/packages/core/test/corpus/fixtures/2026-07-28/ListResourcesRequest/list-resources-request.json new file mode 100644 index 0000000000..0ce2f47025 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListResourcesRequest/list-resources-request.json @@ -0,0 +1,15 @@ +{ + "jsonrpc": "2.0", + "id": "list-resources-example", + "method": "resources/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListResourcesResult/resources-list-with-cursor-and-ttl.json b/packages/core/test/corpus/fixtures/2026-07-28/ListResourcesResult/resources-list-with-cursor-and-ttl.json new file mode 100644 index 0000000000..e701fc6421 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListResourcesResult/resources-list-with-cursor-and-ttl.json @@ -0,0 +1,22 @@ +{ + "resultType": "complete", + "resources": [ + { + "uri": "file:///project/src/main.rs", + "name": "main.rs", + "title": "Rust Software Application Main File", + "description": "Primary application entry point", + "mimeType": "text/x-rust", + "icons": [ + { + "src": "https://example.com/rust-file-icon.png", + "mimeType": "image/png", + "sizes": ["48x48"] + } + ] + } + ], + "nextCursor": "eyJwYWdlIjogM30=", + "ttlMs": 600000, + "cacheScope": "private" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListResourcesResultResponse/list-resources-result-response.json b/packages/core/test/corpus/fixtures/2026-07-28/ListResourcesResultResponse/list-resources-result-response.json new file mode 100644 index 0000000000..e17cc50111 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListResourcesResultResponse/list-resources-result-response.json @@ -0,0 +1,26 @@ +{ + "jsonrpc": "2.0", + "id": "list-resources-example", + "result": { + "resultType": "complete", + "resources": [ + { + "uri": "file:///project/src/main.rs", + "name": "main.rs", + "title": "Rust Software Application Main File", + "description": "Primary application entry point", + "mimeType": "text/x-rust", + "icons": [ + { + "src": "https://example.com/rust-file-icon.png", + "mimeType": "image/png", + "sizes": ["48x48"] + } + ] + } + ], + "nextCursor": "eyJwYWdlIjogM30=", + "ttlMs": 600000, + "cacheScope": "private" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListRootsRequest/list-roots-request.json b/packages/core/test/corpus/fixtures/2026-07-28/ListRootsRequest/list-roots-request.json new file mode 100644 index 0000000000..ef0b0c0c6a --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListRootsRequest/list-roots-request.json @@ -0,0 +1,4 @@ +{ + "id": "list-roots-example", + "method": "roots/list" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListRootsResult/multiple-root-directories.json b/packages/core/test/corpus/fixtures/2026-07-28/ListRootsResult/multiple-root-directories.json new file mode 100644 index 0000000000..0cf0e78c2d --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListRootsResult/multiple-root-directories.json @@ -0,0 +1,12 @@ +{ + "roots": [ + { + "uri": "file:///home/user/repos/frontend", + "name": "Frontend Repository" + }, + { + "uri": "file:///home/user/repos/backend", + "name": "Backend Repository" + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListRootsResult/single-root-directory.json b/packages/core/test/corpus/fixtures/2026-07-28/ListRootsResult/single-root-directory.json new file mode 100644 index 0000000000..0ea6963dcd --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListRootsResult/single-root-directory.json @@ -0,0 +1,8 @@ +{ + "roots": [ + { + "uri": "file:///home/user/projects/myproject", + "name": "My Project" + } + ] +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListToolsRequest/list-tools-request.json b/packages/core/test/corpus/fixtures/2026-07-28/ListToolsRequest/list-tools-request.json new file mode 100644 index 0000000000..02e93eb771 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListToolsRequest/list-tools-request.json @@ -0,0 +1,15 @@ +{ + "jsonrpc": "2.0", + "id": "list-tools-example", + "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListToolsResult/tools-list-with-cursor-and-ttl.json b/packages/core/test/corpus/fixtures/2026-07-28/ListToolsResult/tools-list-with-cursor-and-ttl.json new file mode 100644 index 0000000000..b81f02d4c9 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListToolsResult/tools-list-with-cursor-and-ttl.json @@ -0,0 +1,30 @@ +{ + "resultType": "complete", + "tools": [ + { + "name": "get_weather", + "title": "Weather Information Provider", + "description": "Get current weather information for a location", + "inputSchema": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "City name or zip code" + } + }, + "required": ["location"] + }, + "icons": [ + { + "src": "https://example.com/weather-icon.png", + "mimeType": "image/png", + "sizes": ["48x48"] + } + ] + } + ], + "nextCursor": "next-page-cursor", + "ttlMs": 300000, + "cacheScope": "public" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ListToolsResultResponse/list-tools-result-response.json b/packages/core/test/corpus/fixtures/2026-07-28/ListToolsResultResponse/list-tools-result-response.json new file mode 100644 index 0000000000..1e0c84f9ef --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ListToolsResultResponse/list-tools-result-response.json @@ -0,0 +1,34 @@ +{ + "jsonrpc": "2.0", + "id": "list-tools-example", + "result": { + "resultType": "complete", + "tools": [ + { + "name": "get_weather", + "title": "Weather Information Provider", + "description": "Get current weather information for a location", + "inputSchema": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "City name or zip code" + } + }, + "required": ["location"] + }, + "icons": [ + { + "src": "https://example.com/weather-icon.png", + "mimeType": "image/png", + "sizes": ["48x48"] + } + ] + } + ], + "nextCursor": "next-page-cursor", + "ttlMs": 3600000, + "cacheScope": "public" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/LoggingMessageNotification/log-database-connection-failed.json b/packages/core/test/corpus/fixtures/2026-07-28/LoggingMessageNotification/log-database-connection-failed.json new file mode 100644 index 0000000000..7c131e04bb --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/LoggingMessageNotification/log-database-connection-failed.json @@ -0,0 +1,15 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/message", + "params": { + "level": "error", + "logger": "database", + "data": { + "error": "Connection failed", + "details": { + "host": "localhost", + "port": 5432 + } + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/LoggingMessageNotificationParams/log-database-connection-failed.json b/packages/core/test/corpus/fixtures/2026-07-28/LoggingMessageNotificationParams/log-database-connection-failed.json new file mode 100644 index 0000000000..dad2430eec --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/LoggingMessageNotificationParams/log-database-connection-failed.json @@ -0,0 +1,11 @@ +{ + "level": "error", + "logger": "database", + "data": { + "error": "Connection failed", + "details": { + "host": "localhost", + "port": 5432 + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/MethodNotFoundError/prompts-not-supported.json b/packages/core/test/corpus/fixtures/2026-07-28/MethodNotFoundError/prompts-not-supported.json new file mode 100644 index 0000000000..0a025a1cd1 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/MethodNotFoundError/prompts-not-supported.json @@ -0,0 +1,7 @@ +{ + "code": -32601, + "message": "Prompts not supported", + "data": { + "reason": "Server does not support the prompts capability" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/MissingRequiredClientCapabilityError/missing-elicitation-capability.json b/packages/core/test/corpus/fixtures/2026-07-28/MissingRequiredClientCapabilityError/missing-elicitation-capability.json new file mode 100644 index 0000000000..10917d3603 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/MissingRequiredClientCapabilityError/missing-elicitation-capability.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32003, + "message": "Server requires the elicitation capability for this request", + "data": { + "requiredCapabilities": { + "elicitation": {} + } + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ModelPreferences/with-hints-and-priorities.json b/packages/core/test/corpus/fixtures/2026-07-28/ModelPreferences/with-hints-and-priorities.json new file mode 100644 index 0000000000..44786871db --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ModelPreferences/with-hints-and-priorities.json @@ -0,0 +1,9 @@ +{ + "hints": [ + { "name": "claude-3-sonnet" }, + { "name": "claude" } + ], + "costPriority": 0.3, + "speedPriority": 0.8, + "intelligencePriority": 0.5 +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/NumberSchema/number-input-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/NumberSchema/number-input-schema.json new file mode 100644 index 0000000000..6049ed6636 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/NumberSchema/number-input-schema.json @@ -0,0 +1,8 @@ +{ + "type": "number", + "title": "Display Name", + "description": "Description text", + "minimum": 0, + "maximum": 100, + "default": 50 +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/PaginatedRequestParams/list-with-cursor.json b/packages/core/test/corpus/fixtures/2026-07-28/PaginatedRequestParams/list-with-cursor.json new file mode 100644 index 0000000000..948178be8d --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/PaginatedRequestParams/list-with-cursor.json @@ -0,0 +1,11 @@ +{ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }, + "cursor": "eyJwYWdlIjogMn0=" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ParseError/invalid-json.json b/packages/core/test/corpus/fixtures/2026-07-28/ParseError/invalid-json.json new file mode 100644 index 0000000000..eb47719580 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ParseError/invalid-json.json @@ -0,0 +1,4 @@ +{ + "code": -32700, + "message": "Parse error: Invalid JSON" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ProgressNotification/progress-message.json b/packages/core/test/corpus/fixtures/2026-07-28/ProgressNotification/progress-message.json new file mode 100644 index 0000000000..1e66088b23 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ProgressNotification/progress-message.json @@ -0,0 +1,10 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/progress", + "params": { + "progressToken": "oivaizmir", + "progress": 50, + "total": 100, + "message": "Reticulating splines..." + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ProgressNotificationParams/progress-message.json b/packages/core/test/corpus/fixtures/2026-07-28/ProgressNotificationParams/progress-message.json new file mode 100644 index 0000000000..49549c115f --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ProgressNotificationParams/progress-message.json @@ -0,0 +1,6 @@ +{ + "progressToken": "oivaizmir", + "progress": 50, + "total": 100, + "message": "Reticulating splines..." +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/PromptListChangedNotification/prompts-list-changed.json b/packages/core/test/corpus/fixtures/2026-07-28/PromptListChangedNotification/prompts-list-changed.json new file mode 100644 index 0000000000..858cd5d874 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/PromptListChangedNotification/prompts-list-changed.json @@ -0,0 +1,4 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/prompts/list_changed" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceRequest/read-resource-request.json b/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceRequest/read-resource-request.json new file mode 100644 index 0000000000..073a816eb6 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceRequest/read-resource-request.json @@ -0,0 +1,16 @@ +{ + "jsonrpc": "2.0", + "id": "read-resource-example", + "method": "resources/read", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }, + "uri": "file:///project/src/main.rs" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceResult/file-resource-contents.json b/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceResult/file-resource-contents.json new file mode 100644 index 0000000000..591fd09ce9 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceResult/file-resource-contents.json @@ -0,0 +1,12 @@ +{ + "resultType": "complete", + "contents": [ + { + "uri": "file:///project/src/main.rs", + "mimeType": "text/x-rust", + "text": "fn main() {\n println!(\"Hello world!\");\n}" + } + ], + "ttlMs": 60000, + "cacheScope": "private" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceResultResponse/read-resource-result-response-with-ttl.json b/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceResultResponse/read-resource-result-response-with-ttl.json new file mode 100644 index 0000000000..b63f398a16 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceResultResponse/read-resource-result-response-with-ttl.json @@ -0,0 +1,16 @@ +{ + "jsonrpc": "2.0", + "id": "read-resource-with-ttl-example", + "result": { + "resultType": "complete", + "contents": [ + { + "uri": "file:///project/src/main.rs", + "mimeType": "text/x-rust", + "text": "fn main() {\n println!(\"Hello world!\");\n}" + } + ], + "ttlMs": 60000, + "cacheScope": "private" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceResultResponse/read-resource-result-response.json b/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceResultResponse/read-resource-result-response.json new file mode 100644 index 0000000000..93bfae6943 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ReadResourceResultResponse/read-resource-result-response.json @@ -0,0 +1,14 @@ +{ + "jsonrpc": "2.0", + "id": "read-resource-example", + "result": { + "resultType": "complete", + "contents": [ + { + "uri": "file:///project/src/main.rs", + "mimeType": "text/x-rust", + "text": "fn main() {\n println!(\"Hello world!\");\n}" + } + ] + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/Resource/file-resource-with-annotations.json b/packages/core/test/corpus/fixtures/2026-07-28/Resource/file-resource-with-annotations.json new file mode 100644 index 0000000000..3e268afb1d --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/Resource/file-resource-with-annotations.json @@ -0,0 +1,11 @@ +{ + "uri": "file:///project/README.md", + "name": "README.md", + "title": "Project Documentation", + "mimeType": "text/markdown", + "annotations": { + "audience": ["user"], + "priority": 0.8, + "lastModified": "2025-01-12T15:00:58Z" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ResourceLink/file-resource-link.json b/packages/core/test/corpus/fixtures/2026-07-28/ResourceLink/file-resource-link.json new file mode 100644 index 0000000000..d35682596f --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ResourceLink/file-resource-link.json @@ -0,0 +1,7 @@ +{ + "type": "resource_link", + "uri": "file:///project/src/main.rs", + "name": "main.rs", + "description": "Primary application entry point", + "mimeType": "text/x-rust" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ResourceListChangedNotification/resources-list-changed.json b/packages/core/test/corpus/fixtures/2026-07-28/ResourceListChangedNotification/resources-list-changed.json new file mode 100644 index 0000000000..6ba5e168ec --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ResourceListChangedNotification/resources-list-changed.json @@ -0,0 +1,4 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/resources/list_changed" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ResourceUpdatedNotification/file-resource-updated-notification.json b/packages/core/test/corpus/fixtures/2026-07-28/ResourceUpdatedNotification/file-resource-updated-notification.json new file mode 100644 index 0000000000..b5f9ef67f7 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ResourceUpdatedNotification/file-resource-updated-notification.json @@ -0,0 +1,7 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/resources/updated", + "params": { + "uri": "file:///project/src/main.rs" + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ResourceUpdatedNotificationParams/file-resource-updated.json b/packages/core/test/corpus/fixtures/2026-07-28/ResourceUpdatedNotificationParams/file-resource-updated.json new file mode 100644 index 0000000000..10decf86a2 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ResourceUpdatedNotificationParams/file-resource-updated.json @@ -0,0 +1,3 @@ +{ + "uri": "file:///project/src/main.rs" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/Root/project-directory.json b/packages/core/test/corpus/fixtures/2026-07-28/Root/project-directory.json new file mode 100644 index 0000000000..b3195b3d74 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/Root/project-directory.json @@ -0,0 +1,4 @@ +{ + "uri": "file:///home/user/projects/myproject", + "name": "My Project" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/SamplingMessage/multiple-content-blocks.json b/packages/core/test/corpus/fixtures/2026-07-28/SamplingMessage/multiple-content-blocks.json new file mode 100644 index 0000000000..9190b9f16d --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/SamplingMessage/multiple-content-blocks.json @@ -0,0 +1,15 @@ +{ + "role": "user", + "content": [ + { + "type": "tool_result", + "toolUseId": "call_123", + "content": [{ "type": "text", "text": "Result 1" }] + }, + { + "type": "tool_result", + "toolUseId": "call_456", + "content": [{ "type": "text", "text": "Result 2" }] + } + ] +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/SamplingMessage/single-content-block.json b/packages/core/test/corpus/fixtures/2026-07-28/SamplingMessage/single-content-block.json new file mode 100644 index 0000000000..5aaa0f15c3 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/SamplingMessage/single-content-block.json @@ -0,0 +1,7 @@ +{ + "role": "user", + "content": { + "type": "text", + "text": "What is the capital of France?" + } +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/completions-minimum-baseline-support.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/completions-minimum-baseline-support.json new file mode 100644 index 0000000000..b151d2b774 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/completions-minimum-baseline-support.json @@ -0,0 +1,3 @@ +{ + "completions": {} +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/extensions-tasks.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/extensions-tasks.json new file mode 100644 index 0000000000..10ed90d38d --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/extensions-tasks.json @@ -0,0 +1,5 @@ +{ + "extensions": { + "io.modelcontextprotocol/tasks": {} + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/logging-minimum-baseline-support.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/logging-minimum-baseline-support.json new file mode 100644 index 0000000000..6be7397886 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/logging-minimum-baseline-support.json @@ -0,0 +1,3 @@ +{ + "logging": {} +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/prompts-list-changed-notifications.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/prompts-list-changed-notifications.json new file mode 100644 index 0000000000..0fcacf6154 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/prompts-list-changed-notifications.json @@ -0,0 +1,5 @@ +{ + "prompts": { + "listChanged": true + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/prompts-minimum-baseline-support.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/prompts-minimum-baseline-support.json new file mode 100644 index 0000000000..03b9366156 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/prompts-minimum-baseline-support.json @@ -0,0 +1,3 @@ +{ + "prompts": {} +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-all-notifications.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-all-notifications.json new file mode 100644 index 0000000000..52bc7897e9 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-all-notifications.json @@ -0,0 +1,6 @@ +{ + "resources": { + "subscribe": true, + "listChanged": true + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-list-changed-notifications-only.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-list-changed-notifications-only.json new file mode 100644 index 0000000000..0b144588c1 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-list-changed-notifications-only.json @@ -0,0 +1,5 @@ +{ + "resources": { + "listChanged": true + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-minimum-baseline-support.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-minimum-baseline-support.json new file mode 100644 index 0000000000..d6eebc58e8 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-minimum-baseline-support.json @@ -0,0 +1,3 @@ +{ + "resources": {} +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-subscription-to-individual-resource-updates-only.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-subscription-to-individual-resource-updates-only.json new file mode 100644 index 0000000000..0ec9700ab9 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/resources-subscription-to-individual-resource-updates-only.json @@ -0,0 +1,5 @@ +{ + "resources": { + "subscribe": true + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/tools-list-changed-notifications.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/tools-list-changed-notifications.json new file mode 100644 index 0000000000..73851b6c5c --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/tools-list-changed-notifications.json @@ -0,0 +1,5 @@ +{ + "tools": { + "listChanged": true + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/tools-minimum-baseline-support.json b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/tools-minimum-baseline-support.json new file mode 100644 index 0000000000..2f8e00f819 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ServerCapabilities/tools-minimum-baseline-support.json @@ -0,0 +1,3 @@ +{ + "tools": {} +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/StringSchema/email-input-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/StringSchema/email-input-schema.json new file mode 100644 index 0000000000..8d85641332 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/StringSchema/email-input-schema.json @@ -0,0 +1,9 @@ +{ + "type": "string", + "title": "Display Name", + "description": "Description text", + "minLength": 3, + "maxLength": 50, + "format": "email", + "default": "user@example.com" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/SubscriptionsAcknowledgedNotification/listen-acknowledged.json b/packages/core/test/corpus/fixtures/2026-07-28/SubscriptionsAcknowledgedNotification/listen-acknowledged.json new file mode 100644 index 0000000000..d3e444f9e6 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/SubscriptionsAcknowledgedNotification/listen-acknowledged.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/subscriptions/acknowledged", + "params": { + "_meta": { + "io.modelcontextprotocol/subscriptionId": "listen-1" + }, + "notifications": { + "toolsListChanged": true, + "resourceSubscriptions": ["file:///project/config.json"] + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/SubscriptionsListenRequest/listen-for-list-changes.json b/packages/core/test/corpus/fixtures/2026-07-28/SubscriptionsListenRequest/listen-for-list-changes.json new file mode 100644 index 0000000000..76858b497e --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/SubscriptionsListenRequest/listen-for-list-changes.json @@ -0,0 +1,19 @@ +{ + "jsonrpc": "2.0", + "id": "listen-1", + "method": "subscriptions/listen", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "ExampleClient", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + }, + "notifications": { + "toolsListChanged": true, + "resourceSubscriptions": ["file:///project/config.json"] + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/TextContent/text-content.json b/packages/core/test/corpus/fixtures/2026-07-28/TextContent/text-content.json new file mode 100644 index 0000000000..13df577040 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/TextContent/text-content.json @@ -0,0 +1,4 @@ +{ + "type": "text", + "text": "Tool result text" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/TextResourceContents/text-file-contents.json b/packages/core/test/corpus/fixtures/2026-07-28/TextResourceContents/text-file-contents.json new file mode 100644 index 0000000000..a70f268592 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/TextResourceContents/text-file-contents.json @@ -0,0 +1,5 @@ +{ + "uri": "file:///example.txt", + "mimeType": "text/plain", + "text": "Resource content" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/TitledMultiSelectEnumSchema/titled-color-multi-select-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/TitledMultiSelectEnumSchema/titled-color-multi-select-schema.json new file mode 100644 index 0000000000..e6b9e6f8a0 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/TitledMultiSelectEnumSchema/titled-color-multi-select-schema.json @@ -0,0 +1,15 @@ +{ + "type": "array", + "title": "Color Selection", + "description": "Choose your favorite colors", + "minItems": 1, + "maxItems": 2, + "items": { + "anyOf": [ + { "const": "#FF0000", "title": "Red" }, + { "const": "#00FF00", "title": "Green" }, + { "const": "#0000FF", "title": "Blue" } + ] + }, + "default": ["#FF0000", "#00FF00"] +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/TitledSingleSelectEnumSchema/titled-color-select-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/TitledSingleSelectEnumSchema/titled-color-select-schema.json new file mode 100644 index 0000000000..d1a4689195 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/TitledSingleSelectEnumSchema/titled-color-select-schema.json @@ -0,0 +1,11 @@ +{ + "type": "string", + "title": "Color Selection", + "description": "Choose your favorite color", + "oneOf": [ + { "const": "#FF0000", "title": "Red" }, + { "const": "#00FF00", "title": "Green" }, + { "const": "#0000FF", "title": "Blue" } + ], + "default": "#FF0000" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/Tool/tool-with-array-output-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/Tool/tool-with-array-output-schema.json new file mode 100644 index 0000000000..8c7edec623 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/Tool/tool-with-array-output-schema.json @@ -0,0 +1,30 @@ +{ + "name": "list_users", + "title": "User List", + "description": "Returns a list of all users", + "inputSchema": { + "type": "object", + "properties": {} + }, + "outputSchema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "User ID" + }, + "name": { + "type": "string", + "description": "User name" + }, + "email": { + "type": "string", + "description": "User email" + } + }, + "required": ["id", "name", "email"] + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/Tool/tool-with-composition-input-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/Tool/tool-with-composition-input-schema.json new file mode 100644 index 0000000000..7c7253af9f --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/Tool/tool-with-composition-input-schema.json @@ -0,0 +1,28 @@ +{ + "name": "find_resource", + "title": "Resource Finder", + "description": "Find a resource by ID or name", + "inputSchema": { + "type": "object", + "oneOf": [ + { + "properties": { + "id": { + "type": "string", + "description": "Resource ID" + } + }, + "required": ["id"] + }, + { + "properties": { + "name": { + "type": "string", + "description": "Resource name" + } + }, + "required": ["name"] + } + ] + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-default-2020-12-input-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-default-2020-12-input-schema.json new file mode 100644 index 0000000000..d79a00eeaa --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-default-2020-12-input-schema.json @@ -0,0 +1,12 @@ +{ + "name": "calculate_sum", + "description": "Add two numbers", + "inputSchema": { + "type": "object", + "properties": { + "a": { "type": "number" }, + "b": { "type": "number" } + }, + "required": ["a", "b"] + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-explicit-draft-07-input-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-explicit-draft-07-input-schema.json new file mode 100644 index 0000000000..698d95b865 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-explicit-draft-07-input-schema.json @@ -0,0 +1,13 @@ +{ + "name": "calculate_sum", + "description": "Add two numbers", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "a": { "type": "number" }, + "b": { "type": "number" } + }, + "required": ["a", "b"] + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-no-parameters.json b/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-no-parameters.json new file mode 100644 index 0000000000..04a3a4e956 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-no-parameters.json @@ -0,0 +1,8 @@ +{ + "name": "get_current_time", + "description": "Returns the current server time", + "inputSchema": { + "type": "object", + "additionalProperties": false + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-output-schema-for-structured-content.json b/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-output-schema-for-structured-content.json new file mode 100644 index 0000000000..a146983424 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/Tool/with-output-schema-for-structured-content.json @@ -0,0 +1,33 @@ +{ + "name": "get_weather_data", + "title": "Weather Data Retriever", + "description": "Get current weather data for a location", + "inputSchema": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "City name or zip code" + } + }, + "required": ["location"] + }, + "outputSchema": { + "type": "object", + "properties": { + "temperature": { + "type": "number", + "description": "Temperature in celsius" + }, + "conditions": { + "type": "string", + "description": "Weather conditions description" + }, + "humidity": { + "type": "number", + "description": "Humidity percentage" + } + }, + "required": ["temperature", "conditions", "humidity"] + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ToolListChangedNotification/tools-list-changed.json b/packages/core/test/corpus/fixtures/2026-07-28/ToolListChangedNotification/tools-list-changed.json new file mode 100644 index 0000000000..a28e846763 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ToolListChangedNotification/tools-list-changed.json @@ -0,0 +1,4 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/tools/list_changed" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ToolResultContent/get-weather-tool-result.json b/packages/core/test/corpus/fixtures/2026-07-28/ToolResultContent/get-weather-tool-result.json new file mode 100644 index 0000000000..3b44156d61 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ToolResultContent/get-weather-tool-result.json @@ -0,0 +1,10 @@ +{ + "type": "tool_result", + "toolUseId": "call_abc123", + "content": [ + { + "type": "text", + "text": "Weather in Paris: 18°C, partly cloudy" + } + ] +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/ToolUseContent/get-weather-tool-use.json b/packages/core/test/corpus/fixtures/2026-07-28/ToolUseContent/get-weather-tool-use.json new file mode 100644 index 0000000000..197560de67 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ToolUseContent/get-weather-tool-use.json @@ -0,0 +1,8 @@ +{ + "type": "tool_use", + "id": "call_abc123", + "name": "get_weather", + "input": { + "city": "Paris" + } +} \ No newline at end of file diff --git a/packages/core/test/corpus/fixtures/2026-07-28/UnsupportedProtocolVersionError/unsupported-version.json b/packages/core/test/corpus/fixtures/2026-07-28/UnsupportedProtocolVersionError/unsupported-version.json new file mode 100644 index 0000000000..d4c99b7ce8 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/UnsupportedProtocolVersionError/unsupported-version.json @@ -0,0 +1,12 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32004, + "message": "Unsupported protocol version", + "data": { + "supported": ["2026-07-28", "2025-11-25"], + "requested": "1900-01-01" + } + } +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/UntitledMultiSelectEnumSchema/color-multi-select-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/UntitledMultiSelectEnumSchema/color-multi-select-schema.json new file mode 100644 index 0000000000..d63467e7ee --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/UntitledMultiSelectEnumSchema/color-multi-select-schema.json @@ -0,0 +1,12 @@ +{ + "type": "array", + "title": "Color Selection", + "description": "Choose your favorite colors", + "minItems": 1, + "maxItems": 2, + "items": { + "type": "string", + "enum": ["Red", "Green", "Blue"] + }, + "default": ["Red", "Green"] +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/UntitledSingleSelectEnumSchema/color-select-schema.json b/packages/core/test/corpus/fixtures/2026-07-28/UntitledSingleSelectEnumSchema/color-select-schema.json new file mode 100644 index 0000000000..13e05d5789 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/UntitledSingleSelectEnumSchema/color-select-schema.json @@ -0,0 +1,7 @@ +{ + "type": "string", + "title": "Color Selection", + "description": "Choose your favorite color", + "enum": ["Red", "Green", "Blue"], + "default": "Red" +} diff --git a/packages/core/test/corpus/fixtures/2026-07-28/manifest.json b/packages/core/test/corpus/fixtures/2026-07-28/manifest.json new file mode 100644 index 0000000000..8aa8155edd --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/manifest.json @@ -0,0 +1,312 @@ +{ + "revision": "2026-07-28", + "source": { + "repo": "modelcontextprotocol/modelcontextprotocol", + "path": "schema/draft/examples", + "commit": "0168c57fc74aba6e6dcf8f0b7191db3caaa5ad65" + }, + "regenerate": "pnpm fetch:spec-examples --spec-dir # or [sha] to fetch from GitHub", + "directoryCount": 86, + "fileCount": 127, + "directories": { + "AudioContent": [ + "audio-wav-content.json" + ], + "BlobResourceContents": [ + "image-file-contents.json" + ], + "BooleanSchema": [ + "boolean-input-schema.json" + ], + "CallToolRequest": [ + "call-tool-request.json" + ], + "CallToolRequestParams": [ + "get-weather-tool-call-params.json", + "tool-call-params-with-progress-token.json" + ], + "CallToolResult": [ + "invalid-tool-input-error.json", + "result-with-array-structured-content.json", + "result-with-structured-content.json", + "result-with-unstructured-text.json" + ], + "CallToolResultResponse": [ + "call-tool-result-response.json" + ], + "CancelledNotification": [ + "user-requested-cancellation.json" + ], + "CancelledNotificationParams": [ + "user-requested-cancellation.json" + ], + "ClientCapabilities": [ + "elicitation-form-and-url-mode-support.json", + "elicitation-form-only-implicit.json", + "extensions-ui-mime-types.json", + "roots-minimum-baseline-support.json", + "sampling-context-inclusion-support-deprecated.json", + "sampling-minimum-baseline-support.json", + "sampling-tool-use-support.json" + ], + "CompleteRequest": [ + "completion-request.json" + ], + "CompleteRequestParams": [ + "prompt-argument-completion-with-context.json", + "prompt-argument-completion.json" + ], + "CompleteResult": [ + "multiple-completion-values-with-more-available.json", + "single-completion-value.json" + ], + "CompleteResultResponse": [ + "completion-result-response.json" + ], + "CreateMessageRequest": [ + "sampling-request.json" + ], + "CreateMessageRequestParams": [ + "basic-request.json", + "follow-up-with-tool-results.json", + "request-with-tools.json" + ], + "CreateMessageResult": [ + "final-response.json", + "text-response.json", + "tool-use-response.json" + ], + "DiscoverRequest": [ + "server-discover-request.json" + ], + "DiscoverResult": [ + "server-capabilities-discovery.json" + ], + "DiscoverResultResponse": [ + "discover-result-response.json" + ], + "ElicitationCompleteNotification": [ + "elicitation-complete.json" + ], + "ElicitRequest": [ + "elicitation-request.json" + ], + "ElicitRequestFormParams": [ + "elicit-multiple-fields.json", + "elicit-single-field.json" + ], + "ElicitRequestURLParams": [ + "elicit-sensitive-data.json" + ], + "ElicitResult": [ + "accept-url-mode-no-content.json", + "input-multiple-fields.json", + "input-single-field.json" + ], + "EmbeddedResource": [ + "embedded-file-resource-with-annotations.json" + ], + "GetPromptRequest": [ + "get-prompt-request.json" + ], + "GetPromptRequestParams": [ + "get-code-review-prompt.json" + ], + "GetPromptResult": [ + "code-review-prompt.json" + ], + "GetPromptResultResponse": [ + "get-prompt-result-response.json" + ], + "ImageContent": [ + "image-png-content-with-annotations.json" + ], + "InputRequests": [ + "elicitation-and-sampling-input-requests.json" + ], + "InputRequiredResult": [ + "input-required-result-with-elicitation-and-sampling-and-request-state.json", + "input-required-result-with-request-state-only.json" + ], + "InputResponses": [ + "elicitation-and-sampling-input-responses.json" + ], + "InternalError": [ + "unexpected-error.json" + ], + "InvalidParamsError": [ + "invalid-cursor.json", + "invalid-tool-arguments.json", + "unknown-prompt.json", + "unknown-tool.json" + ], + "ListPromptsRequest": [ + "list-prompts-request.json" + ], + "ListPromptsResult": [ + "prompts-list-with-cursor-and-ttl.json" + ], + "ListPromptsResultResponse": [ + "list-prompts-result-response.json" + ], + "ListResourcesRequest": [ + "list-resources-request.json" + ], + "ListResourcesResult": [ + "resources-list-with-cursor-and-ttl.json" + ], + "ListResourcesResultResponse": [ + "list-resources-result-response.json" + ], + "ListResourceTemplatesRequest": [ + "list-resource-templates-request.json" + ], + "ListResourceTemplatesResult": [ + "resource-templates-list-with-cursor-and-ttl.json" + ], + "ListResourceTemplatesResultResponse": [ + "list-resource-templates-result-response.json" + ], + "ListRootsRequest": [ + "list-roots-request.json" + ], + "ListRootsResult": [ + "multiple-root-directories.json", + "single-root-directory.json" + ], + "ListToolsRequest": [ + "list-tools-request.json" + ], + "ListToolsResult": [ + "tools-list-with-cursor-and-ttl.json" + ], + "ListToolsResultResponse": [ + "list-tools-result-response.json" + ], + "LoggingMessageNotification": [ + "log-database-connection-failed.json" + ], + "LoggingMessageNotificationParams": [ + "log-database-connection-failed.json" + ], + "MethodNotFoundError": [ + "prompts-not-supported.json" + ], + "MissingRequiredClientCapabilityError": [ + "missing-elicitation-capability.json" + ], + "ModelPreferences": [ + "with-hints-and-priorities.json" + ], + "NumberSchema": [ + "number-input-schema.json" + ], + "PaginatedRequestParams": [ + "list-with-cursor.json" + ], + "ParseError": [ + "invalid-json.json" + ], + "ProgressNotification": [ + "progress-message.json" + ], + "ProgressNotificationParams": [ + "progress-message.json" + ], + "PromptListChangedNotification": [ + "prompts-list-changed.json" + ], + "ReadResourceRequest": [ + "read-resource-request.json" + ], + "ReadResourceResult": [ + "file-resource-contents.json" + ], + "ReadResourceResultResponse": [ + "read-resource-result-response-with-ttl.json", + "read-resource-result-response.json" + ], + "Resource": [ + "file-resource-with-annotations.json" + ], + "ResourceLink": [ + "file-resource-link.json" + ], + "ResourceListChangedNotification": [ + "resources-list-changed.json" + ], + "ResourceUpdatedNotification": [ + "file-resource-updated-notification.json" + ], + "ResourceUpdatedNotificationParams": [ + "file-resource-updated.json" + ], + "Root": [ + "project-directory.json" + ], + "SamplingMessage": [ + "multiple-content-blocks.json", + "single-content-block.json" + ], + "ServerCapabilities": [ + "completions-minimum-baseline-support.json", + "extensions-tasks.json", + "logging-minimum-baseline-support.json", + "prompts-list-changed-notifications.json", + "prompts-minimum-baseline-support.json", + "resources-all-notifications.json", + "resources-list-changed-notifications-only.json", + "resources-minimum-baseline-support.json", + "resources-subscription-to-individual-resource-updates-only.json", + "tools-list-changed-notifications.json", + "tools-minimum-baseline-support.json" + ], + "StringSchema": [ + "email-input-schema.json" + ], + "SubscriptionsAcknowledgedNotification": [ + "listen-acknowledged.json" + ], + "SubscriptionsListenRequest": [ + "listen-for-list-changes.json" + ], + "TextContent": [ + "text-content.json" + ], + "TextResourceContents": [ + "text-file-contents.json" + ], + "TitledMultiSelectEnumSchema": [ + "titled-color-multi-select-schema.json" + ], + "TitledSingleSelectEnumSchema": [ + "titled-color-select-schema.json" + ], + "Tool": [ + "tool-with-array-output-schema.json", + "tool-with-composition-input-schema.json", + "with-default-2020-12-input-schema.json", + "with-explicit-draft-07-input-schema.json", + "with-no-parameters.json", + "with-output-schema-for-structured-content.json" + ], + "ToolListChangedNotification": [ + "tools-list-changed.json" + ], + "ToolResultContent": [ + "get-weather-tool-result.json" + ], + "ToolUseContent": [ + "get-weather-tool-use.json" + ], + "UnsupportedProtocolVersionError": [ + "unsupported-version.json" + ], + "UntitledMultiSelectEnumSchema": [ + "color-multi-select-schema.json" + ], + "UntitledSingleSelectEnumSchema": [ + "color-select-schema.json" + ] + } +} diff --git a/packages/core/test/corpus/fixtures/rejection/batch-array-body.json b/packages/core/test/corpus/fixtures/rejection/batch-array-body.json new file mode 100644 index 0000000000..bfea09f9a4 --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/batch-array-body.json @@ -0,0 +1,11 @@ +{ + "description": "JSON-RPC batch arrays were removed in 2025-06-18; an array message is rejected at classification.", + "message": [ + { + "jsonrpc": "2.0", + "id": 6, + "method": "ping" + } + ], + "expect": "onerror" +} diff --git a/packages/core/test/corpus/fixtures/rejection/error-response-unknown-id.json b/packages/core/test/corpus/fixtures/rejection/error-response-unknown-id.json new file mode 100644 index 0000000000..97c74928ac --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/error-response-unknown-id.json @@ -0,0 +1,12 @@ +{ + "description": "An error response whose id matches no in-flight request is reported out-of-band.", + "message": { + "jsonrpc": "2.0", + "id": 98, + "error": { + "code": -32603, + "message": "boom" + } + }, + "expect": "onerror" +} diff --git a/packages/core/test/corpus/fixtures/rejection/invalid-spec-params.json b/packages/core/test/corpus/fixtures/rejection/invalid-spec-params.json new file mode 100644 index 0000000000..5bf8c693f3 --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/invalid-spec-params.json @@ -0,0 +1,13 @@ +{ + "description": "A spec request with params that fail the method schema is answered with an error response (current dispatch surfaces the parse failure as -32603 Internal error).", + "message": { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": 123 + } + }, + "expect": "error-response", + "errorCode": -32603 +} diff --git a/packages/core/test/corpus/fixtures/rejection/notification-invalid-spec-params.json b/packages/core/test/corpus/fixtures/rejection/notification-invalid-spec-params.json new file mode 100644 index 0000000000..7ea984842e --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/notification-invalid-spec-params.json @@ -0,0 +1,11 @@ +{ + "description": "A spec notification whose params fail the method schema is dropped; the failure is reported out-of-band and no response is sent.", + "message": { + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": { + "requestId": true + } + }, + "expect": "onerror" +} diff --git a/packages/core/test/corpus/fixtures/rejection/notification-unknown-method.json b/packages/core/test/corpus/fixtures/rejection/notification-unknown-method.json new file mode 100644 index 0000000000..2409ad03c8 --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/notification-unknown-method.json @@ -0,0 +1,8 @@ +{ + "description": "A notification with no registered handler is silently ignored (no response, no out-of-band error).", + "message": { + "jsonrpc": "2.0", + "method": "notifications/definitely-unknown" + }, + "expect": "ignored" +} diff --git a/packages/core/test/corpus/fixtures/rejection/null-request-id.json b/packages/core/test/corpus/fixtures/rejection/null-request-id.json new file mode 100644 index 0000000000..5517f83f3b --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/null-request-id.json @@ -0,0 +1,9 @@ +{ + "description": "A request id of null is invalid (ids are strings or integers); the message is rejected at classification.", + "message": { + "jsonrpc": "2.0", + "id": null, + "method": "ping" + }, + "expect": "onerror" +} diff --git a/packages/core/test/corpus/fixtures/rejection/request-extra-top-level-key.json b/packages/core/test/corpus/fixtures/rejection/request-extra-top-level-key.json new file mode 100644 index 0000000000..ef0178a1c3 --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/request-extra-top-level-key.json @@ -0,0 +1,11 @@ +{ + "description": "A request envelope with an unknown top-level sibling is not a valid JSON-RPC message; dispatch reports it out-of-band and sends no response.", + "message": { + "jsonrpc": "2.0", + "id": 4, + "method": "ping", + "params": {}, + "extraTop": true + }, + "expect": "onerror" +} diff --git a/packages/core/test/corpus/fixtures/rejection/result-not-an-object.json b/packages/core/test/corpus/fixtures/rejection/result-not-an-object.json new file mode 100644 index 0000000000..6d8018b445 --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/result-not-an-object.json @@ -0,0 +1,9 @@ +{ + "description": "A response whose result member is not an object fails envelope classification.", + "message": { + "jsonrpc": "2.0", + "id": 7, + "result": "nope" + }, + "expect": "onerror" +} diff --git a/packages/core/test/corpus/fixtures/rejection/result-response-unknown-id.json b/packages/core/test/corpus/fixtures/rejection/result-response-unknown-id.json new file mode 100644 index 0000000000..1538b29058 --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/result-response-unknown-id.json @@ -0,0 +1,9 @@ +{ + "description": "A result response whose id matches no in-flight request is reported out-of-band.", + "message": { + "jsonrpc": "2.0", + "id": 99, + "result": {} + }, + "expect": "onerror" +} diff --git a/packages/core/test/corpus/fixtures/rejection/unknown-request-method.json b/packages/core/test/corpus/fixtures/rejection/unknown-request-method.json new file mode 100644 index 0000000000..bd5727183f --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/unknown-request-method.json @@ -0,0 +1,11 @@ +{ + "description": "A request whose method is unknown to the receiver is answered with -32601 Method not found.", + "message": { + "jsonrpc": "2.0", + "id": 1, + "method": "vendor/definitely-unknown", + "params": {} + }, + "expect": "error-response", + "errorCode": -32601 +} diff --git a/packages/core/test/corpus/fixtures/rejection/unregistered-spec-method.json b/packages/core/test/corpus/fixtures/rejection/unregistered-spec-method.json new file mode 100644 index 0000000000..f7b8d91062 --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/unregistered-spec-method.json @@ -0,0 +1,13 @@ +{ + "description": "A spec request method with no registered handler is answered with -32601 (handler absence, not schema absence).", + "message": { + "jsonrpc": "2.0", + "id": 2, + "method": "resources/subscribe", + "params": { + "uri": "file:///a.txt" + } + }, + "expect": "error-response", + "errorCode": -32601 +} diff --git a/packages/core/test/corpus/fixtures/rejection/valid-tools-call.json b/packages/core/test/corpus/fixtures/rejection/valid-tools-call.json new file mode 100644 index 0000000000..f25225d874 --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/valid-tools-call.json @@ -0,0 +1,15 @@ +{ + "description": "Accept-side dispatch sanity: a valid tools/call request reaches the registered handler and produces a result response.", + "message": { + "jsonrpc": "2.0", + "id": 8, + "method": "tools/call", + "params": { + "name": "echo", + "arguments": { + "text": "hi" + } + } + }, + "expect": "result-response" +} diff --git a/packages/core/test/corpus/fixtures/rejection/wrong-jsonrpc-version.json b/packages/core/test/corpus/fixtures/rejection/wrong-jsonrpc-version.json new file mode 100644 index 0000000000..04d27bf6e4 --- /dev/null +++ b/packages/core/test/corpus/fixtures/rejection/wrong-jsonrpc-version.json @@ -0,0 +1,9 @@ +{ + "description": "A message with a jsonrpc member other than '2.0' is not a valid JSON-RPC message; dispatch reports it out-of-band and sends no response.", + "message": { + "jsonrpc": "1.0", + "id": 5, + "method": "ping" + }, + "expect": "onerror" +} diff --git a/packages/core/test/corpus/schema-twins/2025-11-25.schema.json b/packages/core/test/corpus/schema-twins/2025-11-25.schema.json new file mode 100644 index 0000000000..9d2e662a26 --- /dev/null +++ b/packages/core/test/corpus/schema-twins/2025-11-25.schema.json @@ -0,0 +1,4058 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed", + "properties": { + "audience": { + "description": "Describes who the intended audience of this object or data is.\n\nIt can include multiple entries to indicate content useful for multiple audiences (e.g., `[\"user\", \"assistant\"]`).", + "items": { + "$ref": "#/$defs/Role" + }, + "type": "array" + }, + "lastModified": { + "description": "The moment the resource was last modified, as an ISO 8601 formatted string.\n\nShould be an ISO 8601 formatted string (e.g., \"2025-01-12T15:00:58Z\").\n\nExamples: last activity timestamp in an open file, timestamp when the resource\nwas attached, etc.", + "type": "string" + }, + "priority": { + "description": "Describes how important this data is for operating the server.\n\nA value of 1 means \"most important,\" and indicates that the data is\neffectively required, while 0 means \"least important,\" and indicates that\nthe data is entirely optional.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "AudioContent": { + "description": "Audio provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded audio data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the audio. Different providers may support different audio types.", + "type": "string" + }, + "type": { + "const": "audio", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "BaseMetadata": { + "description": "Base interface for metadata with name (identifier) and title (display name) properties.", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "BlobResourceContents": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "blob": { + "description": "A base64-encoded string representing the binary data of the item.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "blob", + "uri" + ], + "type": "object" + }, + "BooleanSchema": { + "properties": { + "default": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "const": "boolean", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "CallToolRequest": { + "description": "Used by the client to invoke a tool provided by the server.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tools/call", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CallToolRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CallToolRequestParams": { + "description": "Parameters for a `tools/call` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "arguments": { + "additionalProperties": {}, + "description": "Arguments to use for the tool call.", + "type": "object" + }, + "name": { + "description": "The name of the tool.", + "type": "string" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "CallToolResult": { + "description": "The server's response to a tool call.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "description": "A list of content objects that represent the unstructured result of the tool call.", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool call ended in an error.\n\nIf not set, this is assumed to be false (the call was successful).\n\nAny errors that originate from the tool SHOULD be reported inside the result\nobject, with `isError` set to true, _not_ as an MCP protocol-level error\nresponse. Otherwise, the LLM would not be able to see that an error occurred\nand self-correct.\n\nHowever, any errors in _finding_ the tool, an error indicating that the\nserver does not support tool calls, or any other exceptional conditions,\nshould be reported as an MCP error response.", + "type": "boolean" + }, + "structuredContent": { + "additionalProperties": {}, + "description": "An optional JSON object that represents the structured result of the tool call.", + "type": "object" + } + }, + "required": [ + "content" + ], + "type": "object" + }, + "CancelTaskRequest": { + "description": "A request to cancel a task.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tasks/cancel", + "type": "string" + }, + "params": { + "properties": { + "taskId": { + "description": "The task identifier to cancel.", + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CancelTaskResult": { + "allOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/Task" + } + ], + "description": "The response to a tasks/cancel request." + }, + "CancelledNotification": { + "description": "This notification can be sent by either side to indicate that it is cancelling a previously-issued request.\n\nThe request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished.\n\nThis notification indicates that the result will be unused, so any associated processing SHOULD cease.\n\nA client MUST NOT attempt to cancel its `initialize` request.\n\nFor task cancellation, use the `tasks/cancel` request instead of this notification.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/cancelled", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CancelledNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CancelledNotificationParams": { + "description": "Parameters for a `notifications/cancelled` notification.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "reason": { + "description": "An optional string describing the reason for the cancellation. This MAY be logged or presented to the user.", + "type": "string" + }, + "requestId": { + "$ref": "#/$defs/RequestId", + "description": "The ID of the request to cancel.\n\nThis MUST correspond to the ID of a request previously issued in the same direction.\nThis MUST be provided for cancelling non-task requests.\nThis MUST NOT be used for cancelling tasks (use the `tasks/cancel` request instead)." + } + }, + "type": "object" + }, + "ClientCapabilities": { + "description": "Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities.", + "properties": { + "elicitation": { + "description": "Present if the client supports elicitation from the server.", + "properties": { + "form": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "url": { + "additionalProperties": true, + "properties": {}, + "type": "object" + } + }, + "type": "object" + }, + "experimental": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "description": "Experimental, non-standard capabilities that the client supports.", + "type": "object" + }, + "roots": { + "description": "Present if the client supports listing roots.", + "properties": { + "listChanged": { + "description": "Whether the client supports notifications for changes to the roots list.", + "type": "boolean" + } + }, + "type": "object" + }, + "sampling": { + "description": "Present if the client supports sampling from an LLM.", + "properties": { + "context": { + "additionalProperties": true, + "description": "Whether the client supports context inclusion via includeContext parameter.\nIf not declared, servers SHOULD only use `includeContext: \"none\"` (or omit it).", + "properties": {}, + "type": "object" + }, + "tools": { + "additionalProperties": true, + "description": "Whether the client supports tool use via tools and toolChoice parameters.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + }, + "tasks": { + "description": "Present if the client supports task-augmented requests.", + "properties": { + "cancel": { + "additionalProperties": true, + "description": "Whether this client supports tasks/cancel.", + "properties": {}, + "type": "object" + }, + "list": { + "additionalProperties": true, + "description": "Whether this client supports tasks/list.", + "properties": {}, + "type": "object" + }, + "requests": { + "description": "Specifies which request types can be augmented with tasks.", + "properties": { + "elicitation": { + "description": "Task support for elicitation-related requests.", + "properties": { + "create": { + "additionalProperties": true, + "description": "Whether the client supports task-augmented elicitation/create requests.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + }, + "sampling": { + "description": "Task support for sampling-related requests.", + "properties": { + "createMessage": { + "additionalProperties": true, + "description": "Whether the client supports task-augmented sampling/createMessage requests.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ClientNotification": { + "anyOf": [ + { + "$ref": "#/$defs/CancelledNotification" + }, + { + "$ref": "#/$defs/InitializedNotification" + }, + { + "$ref": "#/$defs/ProgressNotification" + }, + { + "$ref": "#/$defs/TaskStatusNotification" + }, + { + "$ref": "#/$defs/RootsListChangedNotification" + } + ] + }, + "ClientRequest": { + "anyOf": [ + { + "$ref": "#/$defs/InitializeRequest" + }, + { + "$ref": "#/$defs/PingRequest" + }, + { + "$ref": "#/$defs/ListResourcesRequest" + }, + { + "$ref": "#/$defs/ListResourceTemplatesRequest" + }, + { + "$ref": "#/$defs/ReadResourceRequest" + }, + { + "$ref": "#/$defs/SubscribeRequest" + }, + { + "$ref": "#/$defs/UnsubscribeRequest" + }, + { + "$ref": "#/$defs/ListPromptsRequest" + }, + { + "$ref": "#/$defs/GetPromptRequest" + }, + { + "$ref": "#/$defs/ListToolsRequest" + }, + { + "$ref": "#/$defs/CallToolRequest" + }, + { + "$ref": "#/$defs/GetTaskRequest" + }, + { + "$ref": "#/$defs/GetTaskPayloadRequest" + }, + { + "$ref": "#/$defs/CancelTaskRequest" + }, + { + "$ref": "#/$defs/ListTasksRequest" + }, + { + "$ref": "#/$defs/SetLevelRequest" + }, + { + "$ref": "#/$defs/CompleteRequest" + } + ] + }, + "ClientResult": { + "anyOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/GetTaskResult", + "description": "The response to a tasks/get request." + }, + { + "$ref": "#/$defs/GetTaskPayloadResult" + }, + { + "$ref": "#/$defs/CancelTaskResult", + "description": "The response to a tasks/cancel request." + }, + { + "$ref": "#/$defs/ListTasksResult" + }, + { + "$ref": "#/$defs/CreateMessageResult" + }, + { + "$ref": "#/$defs/ListRootsResult" + }, + { + "$ref": "#/$defs/ElicitResult" + } + ] + }, + "CompleteRequest": { + "description": "A request from the client to the server, to ask for completion options.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "completion/complete", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CompleteRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CompleteRequestParams": { + "description": "Parameters for a `completion/complete` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "argument": { + "description": "The argument's information", + "properties": { + "name": { + "description": "The name of the argument", + "type": "string" + }, + "value": { + "description": "The value of the argument to use for completion matching.", + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "type": "object" + }, + "context": { + "description": "Additional, optional context for completions", + "properties": { + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Previously-resolved variables in a URI template or prompt.", + "type": "object" + } + }, + "type": "object" + }, + "ref": { + "anyOf": [ + { + "$ref": "#/$defs/PromptReference" + }, + { + "$ref": "#/$defs/ResourceTemplateReference" + } + ] + } + }, + "required": [ + "argument", + "ref" + ], + "type": "object" + }, + "CompleteResult": { + "description": "The server's response to a completion/complete request", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "completion": { + "properties": { + "hasMore": { + "description": "Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown.", + "type": "boolean" + }, + "total": { + "description": "The total number of completion options available. This can exceed the number of values actually sent in the response.", + "type": "integer" + }, + "values": { + "description": "An array of completion values. Must not exceed 100 items.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "values" + ], + "type": "object" + } + }, + "required": [ + "completion" + ], + "type": "object" + }, + "ContentBlock": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ResourceLink" + }, + { + "$ref": "#/$defs/EmbeddedResource" + } + ] + }, + "CreateMessageRequest": { + "description": "A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "sampling/createMessage", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CreateMessageRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CreateMessageRequestParams": { + "description": "Parameters for a `sampling/createMessage` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "includeContext": { + "description": "A request to include context from one or more MCP servers (including the caller), to be attached to the prompt.\nThe client MAY ignore this request.\n\nDefault is \"none\". Values \"thisServer\" and \"allServers\" are soft-deprecated. Servers SHOULD only use these values if the client\ndeclares ClientCapabilities.sampling.context. These values may be removed in future spec releases.", + "enum": [ + "allServers", + "none", + "thisServer" + ], + "type": "string" + }, + "maxTokens": { + "description": "The requested maximum number of tokens to sample (to prevent runaway completions).\n\nThe client MAY choose to sample fewer tokens than the requested maximum.", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/$defs/SamplingMessage" + }, + "type": "array" + }, + "metadata": { + "additionalProperties": true, + "description": "Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific.", + "properties": {}, + "type": "object" + }, + "modelPreferences": { + "$ref": "#/$defs/ModelPreferences", + "description": "The server's preferences for which model to select. The client MAY ignore these preferences." + }, + "stopSequences": { + "items": { + "type": "string" + }, + "type": "array" + }, + "systemPrompt": { + "description": "An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt.", + "type": "string" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + }, + "temperature": { + "type": "number" + }, + "toolChoice": { + "$ref": "#/$defs/ToolChoice", + "description": "Controls how the model uses tools.\nThe client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared.\nDefault is `{ mode: \"auto\" }`." + }, + "tools": { + "description": "Tools that the model may use during generation.\nThe client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared.", + "items": { + "$ref": "#/$defs/Tool" + }, + "type": "array" + } + }, + "required": [ + "maxTokens", + "messages" + ], + "type": "object" + }, + "CreateMessageResult": { + "description": "The client's response to a sampling/createMessage request from the server.\nThe client should inform the user before returning the sampled message, to allow them\nto inspect the response (human in the loop) and decide whether to allow the server to see it.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + }, + { + "items": { + "$ref": "#/$defs/SamplingMessageContentBlock" + }, + "type": "array" + } + ] + }, + "model": { + "description": "The name of the model that generated the message.", + "type": "string" + }, + "role": { + "$ref": "#/$defs/Role" + }, + "stopReason": { + "description": "The reason why sampling stopped, if known.\n\nStandard values:\n- \"endTurn\": Natural end of the assistant's turn\n- \"stopSequence\": A stop sequence was encountered\n- \"maxTokens\": Maximum token limit was reached\n- \"toolUse\": The model wants to use one or more tools\n\nThis field is an open string to allow for provider-specific stop reasons.", + "type": "string" + } + }, + "required": [ + "content", + "model", + "role" + ], + "type": "object" + }, + "CreateTaskResult": { + "description": "A response to a task-augmented request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "task": { + "$ref": "#/$defs/Task" + } + }, + "required": [ + "task" + ], + "type": "object" + }, + "Cursor": { + "description": "An opaque token used to represent a cursor for pagination.", + "type": "string" + }, + "ElicitRequest": { + "description": "A request from the server to elicit additional information from the user via the client.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "elicitation/create", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ElicitRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ElicitRequestFormParams": { + "description": "The parameters for a request to elicit non-sensitive information from the user via a form in the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "message": { + "description": "The message to present to the user describing what information is being requested.", + "type": "string" + }, + "mode": { + "const": "form", + "description": "The elicitation mode.", + "type": "string" + }, + "requestedSchema": { + "description": "A restricted subset of JSON Schema.\nOnly top-level properties are allowed, without nesting.", + "properties": { + "$schema": { + "type": "string" + }, + "properties": { + "additionalProperties": { + "$ref": "#/$defs/PrimitiveSchemaDefinition" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "properties", + "type" + ], + "type": "object" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + } + }, + "required": [ + "message", + "requestedSchema" + ], + "type": "object" + }, + "ElicitRequestParams": { + "anyOf": [ + { + "$ref": "#/$defs/ElicitRequestURLParams" + }, + { + "$ref": "#/$defs/ElicitRequestFormParams" + } + ], + "description": "The parameters for a request to elicit additional information from the user via the client." + }, + "ElicitRequestURLParams": { + "description": "The parameters for a request to elicit information from the user via a URL in the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "elicitationId": { + "description": "The ID of the elicitation, which must be unique within the context of the server.\nThe client MUST treat this ID as an opaque value.", + "type": "string" + }, + "message": { + "description": "The message to present to the user explaining why the interaction is needed.", + "type": "string" + }, + "mode": { + "const": "url", + "description": "The elicitation mode.", + "type": "string" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + }, + "url": { + "description": "The URL that the user should navigate to.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "elicitationId", + "message", + "mode", + "url" + ], + "type": "object" + }, + "ElicitResult": { + "description": "The client's response to an elicitation request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "action": { + "description": "The user action in response to the elicitation.\n- \"accept\": User submitted the form/confirmed the action\n- \"decline\": User explicitly decline the action\n- \"cancel\": User dismissed without making an explicit choice", + "enum": [ + "accept", + "cancel", + "decline" + ], + "type": "string" + }, + "content": { + "additionalProperties": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "integer", + "boolean" + ] + } + ] + }, + "description": "The submitted form data, only present when action is \"accept\" and mode was \"form\".\nContains values matching the requested schema.\nOmitted for out-of-band mode responses.", + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object" + }, + "ElicitationCompleteNotification": { + "description": "An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/elicitation/complete", + "type": "string" + }, + "params": { + "properties": { + "elicitationId": { + "description": "The ID of the elicitation that completed.", + "type": "string" + } + }, + "required": [ + "elicitationId" + ], + "type": "object" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "EmbeddedResource": { + "description": "The contents of a resource, embedded into a prompt or tool call result.\n\nIt is up to the client how best to render embedded resources for the benefit\nof the LLM and/or the user.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "resource": { + "anyOf": [ + { + "$ref": "#/$defs/TextResourceContents" + }, + { + "$ref": "#/$defs/BlobResourceContents" + } + ] + }, + "type": { + "const": "resource", + "type": "string" + } + }, + "required": [ + "resource", + "type" + ], + "type": "object" + }, + "EmptyResult": { + "$ref": "#/$defs/Result" + }, + "EnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/LegacyTitledEnumSchema" + } + ] + }, + "Error": { + "properties": { + "code": { + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "GetPromptRequest": { + "description": "Used by the client to get a prompt provided by the server.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "prompts/get", + "type": "string" + }, + "params": { + "$ref": "#/$defs/GetPromptRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "GetPromptRequestParams": { + "description": "Parameters for a `prompts/get` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Arguments to use for templating the prompt.", + "type": "object" + }, + "name": { + "description": "The name of the prompt or prompt template.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "GetPromptResult": { + "description": "The server's response to a prompts/get request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "description": { + "description": "An optional description for the prompt.", + "type": "string" + }, + "messages": { + "items": { + "$ref": "#/$defs/PromptMessage" + }, + "type": "array" + } + }, + "required": [ + "messages" + ], + "type": "object" + }, + "GetTaskPayloadRequest": { + "description": "A request to retrieve the result of a completed task.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tasks/result", + "type": "string" + }, + "params": { + "properties": { + "taskId": { + "description": "The task identifier to retrieve results for.", + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "GetTaskPayloadResult": { + "additionalProperties": {}, + "description": "The response to a tasks/result request.\nThe structure matches the result type of the original request.\nFor example, a tools/call task would return the CallToolResult structure.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + }, + "GetTaskRequest": { + "description": "A request to retrieve the state of a task.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tasks/get", + "type": "string" + }, + "params": { + "properties": { + "taskId": { + "description": "The task identifier to query.", + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "GetTaskResult": { + "allOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/Task" + } + ], + "description": "The response to a tasks/get request." + }, + "Icon": { + "description": "An optionally-sized icon that can be displayed in a user interface.", + "properties": { + "mimeType": { + "description": "Optional MIME type override if the source MIME type is missing or generic.\nFor example: `\"image/png\"`, `\"image/jpeg\"`, or `\"image/svg+xml\"`.", + "type": "string" + }, + "sizes": { + "description": "Optional array of strings that specify sizes at which the icon can be used.\nEach string should be in WxH format (e.g., `\"48x48\"`, `\"96x96\"`) or `\"any\"` for scalable formats like SVG.\n\nIf not provided, the client should assume that the icon can be used at any size.", + "items": { + "type": "string" + }, + "type": "array" + }, + "src": { + "description": "A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a\n`data:` URI with Base64-encoded image data.\n\nConsumers SHOULD takes steps to ensure URLs serving icons are from the\nsame domain as the client/server or a trusted domain.\n\nConsumers SHOULD take appropriate precautions when consuming SVGs as they can contain\nexecutable JavaScript.", + "format": "uri", + "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for. `light` indicates\nthe icon is designed to be used with a light background, and `dark` indicates\nthe icon is designed to be used with a dark background.\n\nIf not provided, the client should assume the icon can be used with any theme.", + "enum": [ + "dark", + "light" + ], + "type": "string" + } + }, + "required": [ + "src" + ], + "type": "object" + }, + "Icons": { + "description": "Base interface to add `icons` property.", + "properties": { + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + } + }, + "type": "object" + }, + "ImageContent": { + "description": "An image provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded image data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image. Different providers may support different image types.", + "type": "string" + }, + "type": { + "const": "image", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "Implementation": { + "description": "Describes the MCP implementation.", + "properties": { + "description": { + "description": "An optional human-readable description of what this implementation does.\n\nThis can be used by clients or servers to provide context about their purpose\nand capabilities. For example, a server might describe the types of resources\nor tools it provides, while a client might describe its intended use case.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "description": "An optional URL of the website for this implementation.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, + "InitializeRequest": { + "description": "This request is sent from the client to the server when it first connects, asking it to begin initialization.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "initialize", + "type": "string" + }, + "params": { + "$ref": "#/$defs/InitializeRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "InitializeRequestParams": { + "description": "Parameters for an `initialize` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "capabilities": { + "$ref": "#/$defs/ClientCapabilities" + }, + "clientInfo": { + "$ref": "#/$defs/Implementation" + }, + "protocolVersion": { + "description": "The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well.", + "type": "string" + } + }, + "required": [ + "capabilities", + "clientInfo", + "protocolVersion" + ], + "type": "object" + }, + "InitializeResult": { + "description": "After receiving an initialize request from the client, the server sends this response.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "capabilities": { + "$ref": "#/$defs/ServerCapabilities" + }, + "instructions": { + "description": "Instructions describing how to use the server and its features.\n\nThis can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a \"hint\" to the model. For example, this information MAY be added to the system prompt.", + "type": "string" + }, + "protocolVersion": { + "description": "The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect.", + "type": "string" + }, + "serverInfo": { + "$ref": "#/$defs/Implementation" + } + }, + "required": [ + "capabilities", + "protocolVersion", + "serverInfo" + ], + "type": "object" + }, + "InitializedNotification": { + "description": "This notification is sent from the client to the server after initialization has finished.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/initialized", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCErrorResponse": { + "description": "A response to a request that indicates an error occurred.", + "properties": { + "error": { + "$ref": "#/$defs/Error" + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "JSONRPCMessage": { + "anyOf": [ + { + "$ref": "#/$defs/JSONRPCRequest" + }, + { + "$ref": "#/$defs/JSONRPCNotification" + }, + { + "$ref": "#/$defs/JSONRPCResultResponse" + }, + { + "$ref": "#/$defs/JSONRPCErrorResponse" + } + ], + "description": "Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent." + }, + "JSONRPCNotification": { + "description": "A notification which does not expect a response.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCRequest": { + "description": "A request that expects a response.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCResponse": { + "anyOf": [ + { + "$ref": "#/$defs/JSONRPCResultResponse" + }, + { + "$ref": "#/$defs/JSONRPCErrorResponse" + } + ], + "description": "A response to a request, containing either the result or error." + }, + "JSONRPCResultResponse": { + "description": "A successful (non-error) response to a request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/Result" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "LegacyTitledEnumSchema": { + "description": "Use TitledSingleSelectEnumSchema instead.\nThis interface will be removed in a future version.", + "properties": { + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "enumNames": { + "description": "(Legacy) Display names for enum values.\nNon-standard according to JSON schema 2020-12.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "ListPromptsRequest": { + "description": "Sent from the client to request a list of prompts and prompt templates the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "prompts/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListPromptsResult": { + "description": "The server's response to a prompts/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "prompts": { + "items": { + "$ref": "#/$defs/Prompt" + }, + "type": "array" + } + }, + "required": [ + "prompts" + ], + "type": "object" + }, + "ListResourceTemplatesRequest": { + "description": "Sent from the client to request a list of resource templates the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/templates/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListResourceTemplatesResult": { + "description": "The server's response to a resources/templates/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resourceTemplates": { + "items": { + "$ref": "#/$defs/ResourceTemplate" + }, + "type": "array" + } + }, + "required": [ + "resourceTemplates" + ], + "type": "object" + }, + "ListResourcesRequest": { + "description": "Sent from the client to request a list of resources the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListResourcesResult": { + "description": "The server's response to a resources/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resources": { + "items": { + "$ref": "#/$defs/Resource" + }, + "type": "array" + } + }, + "required": [ + "resources" + ], + "type": "object" + }, + "ListRootsRequest": { + "description": "Sent from the server to request a list of root URIs from the client. Roots allow\nservers to ask for specific directories or files to operate on. A common example\nfor roots is providing a set of repositories or directories a server should operate\non.\n\nThis request is typically used when the server needs to understand the file system\nstructure or access specific locations that the client has permission to read from.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "roots/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/RequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListRootsResult": { + "description": "The client's response to a roots/list request from the server.\nThis result contains an array of Root objects, each representing a root directory\nor file that the server can operate on.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "roots": { + "items": { + "$ref": "#/$defs/Root" + }, + "type": "array" + } + }, + "required": [ + "roots" + ], + "type": "object" + }, + "ListTasksRequest": { + "description": "A request to retrieve a list of tasks.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tasks/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListTasksResult": { + "description": "The response to a tasks/list request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "tasks": { + "items": { + "$ref": "#/$defs/Task" + }, + "type": "array" + } + }, + "required": [ + "tasks" + ], + "type": "object" + }, + "ListToolsRequest": { + "description": "Sent from the client to request a list of tools the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tools/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListToolsResult": { + "description": "The server's response to a tools/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "tools": { + "items": { + "$ref": "#/$defs/Tool" + }, + "type": "array" + } + }, + "required": [ + "tools" + ], + "type": "object" + }, + "LoggingLevel": { + "description": "The severity of a log message.\n\nThese map to syslog message severities, as specified in RFC-5424:\nhttps://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1", + "enum": [ + "alert", + "critical", + "debug", + "emergency", + "error", + "info", + "notice", + "warning" + ], + "type": "string" + }, + "LoggingMessageNotification": { + "description": "JSONRPCNotification of a log message passed from server to client. If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/message", + "type": "string" + }, + "params": { + "$ref": "#/$defs/LoggingMessageNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "LoggingMessageNotificationParams": { + "description": "Parameters for a `notifications/message` notification.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "data": { + "description": "The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here." + }, + "level": { + "$ref": "#/$defs/LoggingLevel", + "description": "The severity of this log message." + }, + "logger": { + "description": "An optional name of the logger issuing this message.", + "type": "string" + } + }, + "required": [ + "data", + "level" + ], + "type": "object" + }, + "ModelHint": { + "description": "Hints to use for model selection.\n\nKeys not declared here are currently left unspecified by the spec and are up\nto the client to interpret.", + "properties": { + "name": { + "description": "A hint for a model name.\n\nThe client SHOULD treat this as a substring of a model name; for example:\n - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022`\n - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc.\n - `claude` should match any Claude model\n\nThe client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example:\n - `gemini-1.5-flash` could match `claude-3-haiku-20240307`", + "type": "string" + } + }, + "type": "object" + }, + "ModelPreferences": { + "description": "The server's preferences for model selection, requested of the client during sampling.\n\nBecause LLMs can vary along multiple dimensions, choosing the \"best\" model is\nrarely straightforward. Different models excel in different areas—some are\nfaster but less capable, others are more capable but more expensive, and so\non. This interface allows servers to express their priorities across multiple\ndimensions to help clients make an appropriate selection for their use case.\n\nThese preferences are always advisory. The client MAY ignore them. It is also\nup to the client to decide how to interpret these preferences and how to\nbalance them against other considerations.", + "properties": { + "costPriority": { + "description": "How much to prioritize cost when selecting a model. A value of 0 means cost\nis not important, while a value of 1 means cost is the most important\nfactor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "hints": { + "description": "Optional hints to use for model selection.\n\nIf multiple hints are specified, the client MUST evaluate them in order\n(such that the first match is taken).\n\nThe client SHOULD prioritize these hints over the numeric priorities, but\nMAY still use the priorities to select from ambiguous matches.", + "items": { + "$ref": "#/$defs/ModelHint" + }, + "type": "array" + }, + "intelligencePriority": { + "description": "How much to prioritize intelligence and capabilities when selecting a\nmodel. A value of 0 means intelligence is not important, while a value of 1\nmeans intelligence is the most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "speedPriority": { + "description": "How much to prioritize sampling speed (latency) when selecting a model. A\nvalue of 0 means speed is not important, while a value of 1 means speed is\nthe most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "MultiSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + } + ] + }, + "Notification": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "NotificationParams": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + }, + "NumberSchema": { + "properties": { + "default": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "maximum": { + "type": "integer" + }, + "minimum": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "enum": [ + "integer", + "number" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "PaginatedRequest": { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "PaginatedRequestParams": { + "description": "Common parameters for paginated requests.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "type": "object" + }, + "PaginatedResult": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + } + }, + "type": "object" + }, + "PingRequest": { + "description": "A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "ping", + "type": "string" + }, + "params": { + "$ref": "#/$defs/RequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "PrimitiveSchemaDefinition": { + "anyOf": [ + { + "$ref": "#/$defs/StringSchema" + }, + { + "$ref": "#/$defs/NumberSchema" + }, + { + "$ref": "#/$defs/BooleanSchema" + }, + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/LegacyTitledEnumSchema" + } + ], + "description": "Restricted schema definitions that only allow primitive types\nwithout nested objects or arrays." + }, + "ProgressNotification": { + "description": "An out-of-band notification used to inform the receiver of a progress update for a long-running request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/progress", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ProgressNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ProgressNotificationParams": { + "description": "Parameters for a `notifications/progress` notification.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "message": { + "description": "An optional message describing the current progress.", + "type": "string" + }, + "progress": { + "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", + "type": "number" + }, + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "The progress token which was given in the initial request, used to associate this notification with the request that is proceeding." + }, + "total": { + "description": "Total number of items to process (or total progress required), if known.", + "type": "number" + } + }, + "required": [ + "progress", + "progressToken" + ], + "type": "object" + }, + "ProgressToken": { + "description": "A progress token, used to associate progress notifications with the original request.", + "type": [ + "string", + "integer" + ] + }, + "Prompt": { + "description": "A prompt or prompt template that the server offers.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "arguments": { + "description": "A list of arguments to use for templating the prompt.", + "items": { + "$ref": "#/$defs/PromptArgument" + }, + "type": "array" + }, + "description": { + "description": "An optional description of what this prompt provides", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptArgument": { + "description": "Describes an argument that a prompt can accept.", + "properties": { + "description": { + "description": "A human-readable description of the argument.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "required": { + "description": "Whether this argument must be provided.", + "type": "boolean" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/prompts/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "PromptMessage": { + "description": "Describes a message returned as part of a prompt.\n\nThis is similar to `SamplingMessage`, but also supports the embedding of\nresources from the MCP server.", + "properties": { + "content": { + "$ref": "#/$defs/ContentBlock" + }, + "role": { + "$ref": "#/$defs/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "PromptReference": { + "description": "Identifies a prompt.", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "type": { + "const": "ref/prompt", + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "type": "object" + }, + "ReadResourceRequest": { + "description": "Sent from the client to the server, to read a specific resource URI.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/read", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ReadResourceRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ReadResourceRequestParams": { + "description": "Parameters for a `resources/read` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "ReadResourceResult": { + "description": "The server's response to a resources/read request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "contents": { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/TextResourceContents" + }, + { + "$ref": "#/$defs/BlobResourceContents" + } + ] + }, + "type": "array" + } + }, + "required": [ + "contents" + ], + "type": "object" + }, + "RelatedTaskMetadata": { + "description": "Metadata for associating messages with a task.\nInclude this in the `_meta` field under the key `io.modelcontextprotocol/related-task`.", + "properties": { + "taskId": { + "description": "The task identifier this message is associated with.", + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + }, + "Request": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "RequestId": { + "description": "A uniquely identifying ID for a request in JSON-RPC.", + "type": [ + "string", + "integer" + ] + }, + "RequestParams": { + "description": "Common params for any request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + } + }, + "type": "object" + }, + "Resource": { + "description": "A known resource that the server is capable of reading.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "uri" + ], + "type": "object" + }, + "ResourceContents": { + "description": "The contents of a specific resource or sub-resource.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "ResourceLink": { + "description": "A resource that the server is capable of reading, included in a prompt or tool call result.\n\nNote: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "type": { + "const": "resource_link", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "type", + "uri" + ], + "type": "object" + }, + "ResourceListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/resources/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "ResourceRequestParams": { + "description": "Common parameters when working with resources.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "ResourceTemplate": { + "description": "A template description for resources available on the server.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this template is for.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "uriTemplate": { + "description": "A URI template (according to RFC 6570) that can be used to construct resource URIs.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "name", + "uriTemplate" + ], + "type": "object" + }, + "ResourceTemplateReference": { + "description": "A reference to a resource or resource template definition.", + "properties": { + "type": { + "const": "ref/resource", + "type": "string" + }, + "uri": { + "description": "The URI or URI template of the resource.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "type", + "uri" + ], + "type": "object" + }, + "ResourceUpdatedNotification": { + "description": "A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/resources/updated", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ResourceUpdatedNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ResourceUpdatedNotificationParams": { + "description": "Parameters for a `notifications/resources/updated` notification.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "uri": { + "description": "The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "Result": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + }, + "Role": { + "description": "The sender or recipient of messages and data in a conversation.", + "enum": [ + "assistant", + "user" + ], + "type": "string" + }, + "Root": { + "description": "Represents a root directory or file that the server can operate on.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "name": { + "description": "An optional name for the root. This can be used to provide a human-readable\nidentifier for the root, which may be useful for display purposes or for\nreferencing the root in other parts of the application.", + "type": "string" + }, + "uri": { + "description": "The URI identifying the root. This *must* start with file:// for now.\nThis restriction may be relaxed in future versions of the protocol to allow\nother URI schemes.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "RootsListChangedNotification": { + "description": "A notification from the client to the server, informing it that the list of roots has changed.\nThis notification should be sent whenever the client adds, removes, or modifies any root.\nThe server should then request an updated list of roots using the ListRootsRequest.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/roots/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "SamplingMessage": { + "description": "Describes a message issued to or received from an LLM API.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + }, + { + "items": { + "$ref": "#/$defs/SamplingMessageContentBlock" + }, + "type": "array" + } + ] + }, + "role": { + "$ref": "#/$defs/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "SamplingMessageContentBlock": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + } + ] + }, + "ServerCapabilities": { + "description": "Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities.", + "properties": { + "completions": { + "additionalProperties": true, + "description": "Present if the server supports argument autocompletion suggestions.", + "properties": {}, + "type": "object" + }, + "experimental": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "description": "Experimental, non-standard capabilities that the server supports.", + "type": "object" + }, + "logging": { + "additionalProperties": true, + "description": "Present if the server supports sending log messages to the client.", + "properties": {}, + "type": "object" + }, + "prompts": { + "description": "Present if the server offers any prompt templates.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the prompt list.", + "type": "boolean" + } + }, + "type": "object" + }, + "resources": { + "description": "Present if the server offers any resources to read.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the resource list.", + "type": "boolean" + }, + "subscribe": { + "description": "Whether this server supports subscribing to resource updates.", + "type": "boolean" + } + }, + "type": "object" + }, + "tasks": { + "description": "Present if the server supports task-augmented requests.", + "properties": { + "cancel": { + "additionalProperties": true, + "description": "Whether this server supports tasks/cancel.", + "properties": {}, + "type": "object" + }, + "list": { + "additionalProperties": true, + "description": "Whether this server supports tasks/list.", + "properties": {}, + "type": "object" + }, + "requests": { + "description": "Specifies which request types can be augmented with tasks.", + "properties": { + "tools": { + "description": "Task support for tool-related requests.", + "properties": { + "call": { + "additionalProperties": true, + "description": "Whether the server supports task-augmented tools/call requests.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "tools": { + "description": "Present if the server offers any tools to call.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the tool list.", + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ServerNotification": { + "anyOf": [ + { + "$ref": "#/$defs/CancelledNotification" + }, + { + "$ref": "#/$defs/ProgressNotification" + }, + { + "$ref": "#/$defs/ResourceListChangedNotification" + }, + { + "$ref": "#/$defs/ResourceUpdatedNotification" + }, + { + "$ref": "#/$defs/PromptListChangedNotification" + }, + { + "$ref": "#/$defs/ToolListChangedNotification" + }, + { + "$ref": "#/$defs/TaskStatusNotification" + }, + { + "$ref": "#/$defs/LoggingMessageNotification" + }, + { + "$ref": "#/$defs/ElicitationCompleteNotification" + } + ] + }, + "ServerRequest": { + "anyOf": [ + { + "$ref": "#/$defs/PingRequest" + }, + { + "$ref": "#/$defs/GetTaskRequest" + }, + { + "$ref": "#/$defs/GetTaskPayloadRequest" + }, + { + "$ref": "#/$defs/CancelTaskRequest" + }, + { + "$ref": "#/$defs/ListTasksRequest" + }, + { + "$ref": "#/$defs/CreateMessageRequest" + }, + { + "$ref": "#/$defs/ListRootsRequest" + }, + { + "$ref": "#/$defs/ElicitRequest" + } + ] + }, + "ServerResult": { + "anyOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/InitializeResult" + }, + { + "$ref": "#/$defs/ListResourcesResult" + }, + { + "$ref": "#/$defs/ListResourceTemplatesResult" + }, + { + "$ref": "#/$defs/ReadResourceResult" + }, + { + "$ref": "#/$defs/ListPromptsResult" + }, + { + "$ref": "#/$defs/GetPromptResult" + }, + { + "$ref": "#/$defs/ListToolsResult" + }, + { + "$ref": "#/$defs/CallToolResult" + }, + { + "$ref": "#/$defs/GetTaskResult", + "description": "The response to a tasks/get request." + }, + { + "$ref": "#/$defs/GetTaskPayloadResult" + }, + { + "$ref": "#/$defs/CancelTaskResult", + "description": "The response to a tasks/cancel request." + }, + { + "$ref": "#/$defs/ListTasksResult" + }, + { + "$ref": "#/$defs/CompleteResult" + } + ] + }, + "SetLevelRequest": { + "description": "A request from the client to the server, to enable or adjust logging.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "logging/setLevel", + "type": "string" + }, + "params": { + "$ref": "#/$defs/SetLevelRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "SetLevelRequestParams": { + "description": "Parameters for a `logging/setLevel` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "level": { + "$ref": "#/$defs/LoggingLevel", + "description": "The level of logging that the client wants to receive from the server. The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/message." + } + }, + "required": [ + "level" + ], + "type": "object" + }, + "SingleSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + } + ] + }, + "StringSchema": { + "properties": { + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "format": { + "enum": [ + "date", + "date-time", + "email", + "uri" + ], + "type": "string" + }, + "maxLength": { + "type": "integer" + }, + "minLength": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "SubscribeRequest": { + "description": "Sent from the client to request resources/updated notifications from the server whenever a particular resource changes.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/subscribe", + "type": "string" + }, + "params": { + "$ref": "#/$defs/SubscribeRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "SubscribeRequestParams": { + "description": "Parameters for a `resources/subscribe` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "Task": { + "description": "Data associated with a task.", + "properties": { + "createdAt": { + "description": "ISO 8601 timestamp when the task was created.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO 8601 timestamp when the task was last updated.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval in milliseconds.", + "type": "integer" + }, + "status": { + "$ref": "#/$defs/TaskStatus", + "description": "Current task state." + }, + "statusMessage": { + "description": "Optional human-readable message describing the current task state.\nThis can provide context for any status, including:\n- Reasons for \"cancelled\" status\n- Summaries for \"completed\" status\n- Diagnostic information for \"failed\" status (e.g., error details, what went wrong)", + "type": "string" + }, + "taskId": { + "description": "The task identifier.", + "type": "string" + }, + "ttl": { + "description": "Actual retention duration from creation in milliseconds, null for unlimited.", + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "createdAt", + "lastUpdatedAt", + "status", + "taskId", + "ttl" + ], + "type": "object" + }, + "TaskAugmentedRequestParams": { + "description": "Common params for any task-augmented request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + } + }, + "type": "object" + }, + "TaskMetadata": { + "description": "Metadata for augmenting a request with task execution.\nInclude this in the `task` field of the request parameters.", + "properties": { + "ttl": { + "description": "Requested duration in milliseconds to retain task from creation.", + "type": "integer" + } + }, + "type": "object" + }, + "TaskStatus": { + "description": "The status of a task.", + "enum": [ + "cancelled", + "completed", + "failed", + "input_required", + "working" + ], + "type": "string" + }, + "TaskStatusNotification": { + "description": "An optional notification from the receiver to the requestor, informing them that a task's status has changed. Receivers are not required to send these notifications.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/tasks/status", + "type": "string" + }, + "params": { + "$ref": "#/$defs/TaskStatusNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "TaskStatusNotificationParams": { + "allOf": [ + { + "$ref": "#/$defs/NotificationParams" + }, + { + "$ref": "#/$defs/Task" + } + ], + "description": "Parameters for a `notifications/tasks/status` notification." + }, + "TextContent": { + "description": "Text provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "text": { + "description": "The text content of the message.", + "type": "string" + }, + "type": { + "const": "text", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "type": "object" + }, + "TextResourceContents": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "text": { + "description": "The text of the item. This must only be set if the item can actually be represented as text (not binary data).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "text", + "uri" + ], + "type": "object" + }, + "TitledMultiSelectEnumSchema": { + "description": "Schema for multiple-selection enumeration with display titles for each option.", + "properties": { + "default": { + "description": "Optional default value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "items": { + "description": "Schema for array items with enum options and display labels.", + "properties": { + "anyOf": { + "description": "Array of enum options with values and display labels.", + "items": { + "properties": { + "const": { + "description": "The constant enum value.", + "type": "string" + }, + "title": { + "description": "Display title for this option.", + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "anyOf" + ], + "type": "object" + }, + "maxItems": { + "description": "Maximum number of items to select.", + "type": "integer" + }, + "minItems": { + "description": "Minimum number of items to select.", + "type": "integer" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "array", + "type": "string" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "TitledSingleSelectEnumSchema": { + "description": "Schema for single-selection enumeration with display titles for each option.", + "properties": { + "default": { + "description": "Optional default value.", + "type": "string" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "oneOf": { + "description": "Array of enum options with values and display labels.", + "items": { + "properties": { + "const": { + "description": "The enum value.", + "type": "string" + }, + "title": { + "description": "Display label for this option.", + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "type": "array" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "oneOf", + "type" + ], + "type": "object" + }, + "Tool": { + "description": "Definition for a tool the client can call.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/ToolAnnotations", + "description": "Optional additional tool information.\n\nDisplay name precedence order is: title, annotations.title, then name." + }, + "description": { + "description": "A human-readable description of the tool.\n\nThis can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "execution": { + "$ref": "#/$defs/ToolExecution", + "description": "Execution-related properties for this tool." + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "inputSchema": { + "description": "A JSON Schema object defining the expected parameters for the tool.", + "properties": { + "$schema": { + "type": "string" + }, + "properties": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "outputSchema": { + "description": "An optional JSON Schema object defining the structure of the tool's output returned in\nthe structuredContent field of a CallToolResult.\n\nDefaults to JSON Schema 2020-12 when no explicit $schema is provided.\nCurrently restricted to type: \"object\" at the root level.", + "properties": { + "$schema": { + "type": "string" + }, + "properties": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "inputSchema", + "name" + ], + "type": "object" + }, + "ToolAnnotations": { + "description": "Additional properties describing a Tool to clients.\n\nNOTE: all properties in ToolAnnotations are **hints**.\nThey are not guaranteed to provide a faithful description of\ntool behavior (including descriptive properties like `title`).\n\nClients should never make tool use decisions based on ToolAnnotations\nreceived from untrusted servers.", + "properties": { + "destructiveHint": { + "description": "If true, the tool may perform destructive updates to its environment.\nIf false, the tool performs only additive updates.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: true", + "type": "boolean" + }, + "idempotentHint": { + "description": "If true, calling the tool repeatedly with the same arguments\nwill have no additional effect on its environment.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: false", + "type": "boolean" + }, + "openWorldHint": { + "description": "If true, this tool may interact with an \"open world\" of external\nentities. If false, the tool's domain of interaction is closed.\nFor example, the world of a web search tool is open, whereas that\nof a memory tool is not.\n\nDefault: true", + "type": "boolean" + }, + "readOnlyHint": { + "description": "If true, the tool does not modify its environment.\n\nDefault: false", + "type": "boolean" + }, + "title": { + "description": "A human-readable title for the tool.", + "type": "string" + } + }, + "type": "object" + }, + "ToolChoice": { + "description": "Controls tool selection behavior for sampling requests.", + "properties": { + "mode": { + "description": "Controls the tool use ability of the model:\n- \"auto\": Model decides whether to use tools (default)\n- \"required\": Model MUST use at least one tool before completing\n- \"none\": Model MUST NOT use any tools", + "enum": [ + "auto", + "none", + "required" + ], + "type": "string" + } + }, + "type": "object" + }, + "ToolExecution": { + "description": "Execution-related properties for a tool.", + "properties": { + "taskSupport": { + "description": "Indicates whether this tool supports task-augmented execution.\nThis allows clients to handle long-running operations through polling\nthe task system.\n\n- \"forbidden\": Tool does not support task-augmented execution (default when absent)\n- \"optional\": Tool may support task-augmented execution\n- \"required\": Tool requires task-augmented execution\n\nDefault: \"forbidden\"", + "enum": [ + "forbidden", + "optional", + "required" + ], + "type": "string" + } + }, + "type": "object" + }, + "ToolListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/tools/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "ToolResultContent": { + "description": "The result of a tool use, provided by the user back to the assistant.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "Optional metadata about the tool result. Clients SHOULD preserve this field when\nincluding tool results in subsequent sampling requests to enable caching optimizations.\n\nSee [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "description": "The unstructured result content of the tool use.\n\nThis has the same format as CallToolResult.content and can include text, images,\naudio, resource links, and embedded resources.", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool use resulted in an error.\n\nIf true, the content typically describes the error that occurred.\nDefault: false", + "type": "boolean" + }, + "structuredContent": { + "additionalProperties": {}, + "description": "An optional structured result object.\n\nIf the tool defined an outputSchema, this SHOULD conform to that schema.", + "type": "object" + }, + "toolUseId": { + "description": "The ID of the tool use this result corresponds to.\n\nThis MUST match the ID from a previous ToolUseContent.", + "type": "string" + }, + "type": { + "const": "tool_result", + "type": "string" + } + }, + "required": [ + "content", + "toolUseId", + "type" + ], + "type": "object" + }, + "ToolUseContent": { + "description": "A request from the assistant to call a tool.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "Optional metadata about the tool use. Clients SHOULD preserve this field when\nincluding tool uses in subsequent sampling requests to enable caching optimizations.\n\nSee [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "id": { + "description": "A unique identifier for this tool use.\n\nThis ID is used to match tool results to their corresponding tool uses.", + "type": "string" + }, + "input": { + "additionalProperties": {}, + "description": "The arguments to pass to the tool, conforming to the tool's input schema.", + "type": "object" + }, + "name": { + "description": "The name of the tool to call.", + "type": "string" + }, + "type": { + "const": "tool_use", + "type": "string" + } + }, + "required": [ + "id", + "input", + "name", + "type" + ], + "type": "object" + }, + "URLElicitationRequiredError": { + "description": "An error response that indicates that the server requires the client to provide additional information via an elicitation request.", + "properties": { + "error": { + "allOf": [ + { + "$ref": "#/$defs/Error" + }, + { + "properties": { + "code": { + "const": -32042, + "type": "integer" + }, + "data": { + "additionalProperties": {}, + "properties": { + "elicitations": { + "items": { + "$ref": "#/$defs/ElicitRequestURLParams" + }, + "type": "array" + } + }, + "required": [ + "elicitations" + ], + "type": "object" + } + }, + "required": [ + "code", + "data" + ], + "type": "object" + } + ] + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "UnsubscribeRequest": { + "description": "Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/unsubscribe", + "type": "string" + }, + "params": { + "$ref": "#/$defs/UnsubscribeRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "UnsubscribeRequestParams": { + "description": "Parameters for a `resources/unsubscribe` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "UntitledMultiSelectEnumSchema": { + "description": "Schema for multiple-selection enumeration without display titles for options.", + "properties": { + "default": { + "description": "Optional default value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "items": { + "description": "Schema for the array items.", + "properties": { + "enum": { + "description": "Array of enum values to choose from.", + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "maxItems": { + "description": "Maximum number of items to select.", + "type": "integer" + }, + "minItems": { + "description": "Minimum number of items to select.", + "type": "integer" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "array", + "type": "string" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "UntitledSingleSelectEnumSchema": { + "description": "Schema for single-selection enumeration without display titles for options.", + "properties": { + "default": { + "description": "Optional default value.", + "type": "string" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "enum": { + "description": "Array of enum values to choose from.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + } + } +} + diff --git a/packages/core/test/corpus/schema-twins/2026-07-28.schema.json b/packages/core/test/corpus/schema-twins/2026-07-28.schema.json new file mode 100644 index 0000000000..5ce9df12e4 --- /dev/null +++ b/packages/core/test/corpus/schema-twins/2026-07-28.schema.json @@ -0,0 +1,3881 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed", + "properties": { + "audience": { + "description": "Describes who the intended audience of this object or data is.\n\nIt can include multiple entries to indicate content useful for multiple audiences (e.g., `[\"user\", \"assistant\"]`).", + "items": { + "$ref": "#/$defs/Role" + }, + "type": "array" + }, + "lastModified": { + "description": "The moment the resource was last modified, as an ISO 8601 formatted string.\n\nShould be an ISO 8601 formatted string (e.g., \"2025-01-12T15:00:58Z\").\n\nExamples: last activity timestamp in an open file, timestamp when the resource\nwas attached, etc.", + "type": "string" + }, + "priority": { + "description": "Describes how important this data is for operating the server.\n\nA value of 1 means \"most important,\" and indicates that the data is\neffectively required, while 0 means \"least important,\" and indicates that\nthe data is entirely optional.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "AudioContent": { + "description": "Audio provided to or from an LLM.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded audio data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the audio. Different providers may support different audio types.", + "type": "string" + }, + "type": { + "const": "audio", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "BaseMetadata": { + "description": "Base interface for metadata with name (identifier) and title (display name) properties.", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "BlobResourceContents": { + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "blob": { + "description": "A base64-encoded string representing the binary data of the item.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "blob", + "uri" + ], + "type": "object" + }, + "BooleanSchema": { + "properties": { + "default": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "const": "boolean", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "CacheableResult": { + "description": "A result that supports a time-to-live (TTL) hint for client-side caching.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: Any client or intermediary (e.g., shared gateway, proxy)\n MAY cache the response and serve it to any user.\n- `\"private\"`: Only the requesting user's client MAY cache the response.\n Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached\n copy to a different user.", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "resultType", + "ttlMs" + ], + "type": "object" + }, + "CallToolRequest": { + "description": "Used by the client to invoke a tool provided by the server.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tools/call", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CallToolRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CallToolRequestParams": { + "description": "Parameters for a `tools/call` request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "arguments": { + "additionalProperties": {}, + "description": "Arguments to use for the tool call.", + "type": "object" + }, + "inputResponses": { + "$ref": "#/$defs/InputResponses" + }, + "name": { + "description": "The name of the tool.", + "type": "string" + }, + "requestState": { + "type": "string" + } + }, + "required": [ + "_meta", + "name" + ], + "type": "object" + }, + "CallToolResult": { + "description": "The result returned by the server for a {@link CallToolRequesttools/call} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "content": { + "description": "A list of content objects that represent the unstructured result of the tool call.", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool call ended in an error.\n\nIf not set, this is assumed to be false (the call was successful).\n\nAny errors that originate from the tool SHOULD be reported inside the result\nobject, with `isError` set to true, _not_ as an MCP protocol-level error\nresponse. Otherwise, the LLM would not be able to see that an error occurred\nand self-correct.\n\nHowever, any errors in _finding_ the tool, an error indicating that the\nserver does not support tool calls, or any other exceptional conditions,\nshould be reported as an MCP error response.", + "type": "boolean" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "structuredContent": { + "description": "An optional JSON value that represents the structured result of the tool call.\n\nThis can be any JSON value (object, array, string, number, boolean, or null)\nthat conforms to the tool's outputSchema if one is defined." + } + }, + "required": [ + "content", + "resultType" + ], + "type": "object" + }, + "CallToolResultResponse": { + "description": "A successful response from the server for a {@link CallToolRequesttools/call} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "anyOf": [ + { + "$ref": "#/$defs/InputRequiredResult" + }, + { + "$ref": "#/$defs/CallToolResult" + } + ] + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "CancelledNotification": { + "description": "This notification can be sent by either side to indicate that it is cancelling a previously-issued request.\n\nThe request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished.\n\nThis notification indicates that the result will be unused, so any associated processing SHOULD cease.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/cancelled", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CancelledNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CancelledNotificationParams": { + "description": "Parameters for a `notifications/cancelled` notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "reason": { + "description": "An optional string describing the reason for the cancellation. This MAY be logged or presented to the user.", + "type": "string" + }, + "requestId": { + "$ref": "#/$defs/RequestId", + "description": "The ID of the request to cancel.\n\nThis MUST correspond to the ID of a request previously issued in the same direction." + } + }, + "type": "object" + }, + "ClientCapabilities": { + "description": "Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities.", + "properties": { + "elicitation": { + "description": "Present if the client supports elicitation from the server.", + "properties": { + "form": { + "$ref": "#/$defs/JSONObject" + }, + "url": { + "$ref": "#/$defs/JSONObject" + } + }, + "type": "object" + }, + "experimental": { + "additionalProperties": { + "$ref": "#/$defs/JSONObject" + }, + "description": "Experimental, non-standard capabilities that the client supports.", + "type": "object" + }, + "extensions": { + "additionalProperties": { + "$ref": "#/$defs/JSONObject" + }, + "description": "Optional MCP extensions that the client supports. Keys are extension identifiers\n(e.g., \"io.modelcontextprotocol/oauth-client-credentials\"), and values are\nper-extension settings objects. An empty object indicates support with no settings.\n\nKeys MUST follow the {@link MetaObject`_meta` key naming rules}, with a\nmandatory prefix.", + "type": "object" + }, + "roots": { + "description": "Present if the client supports listing roots.", + "properties": {}, + "type": "object" + }, + "sampling": { + "description": "Present if the client supports sampling from an LLM.", + "properties": { + "context": { + "$ref": "#/$defs/JSONObject", + "description": "Whether the client supports context inclusion via `includeContext` parameter.\nIf not declared, servers SHOULD only use `includeContext: \"none\"` (or omit it)." + }, + "tools": { + "$ref": "#/$defs/JSONObject", + "description": "Whether the client supports tool use via `tools` and `toolChoice` parameters." + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ClientNotification": { + "anyOf": [ + { + "$ref": "#/$defs/CancelledNotification" + }, + { + "$ref": "#/$defs/ProgressNotification" + } + ] + }, + "ClientRequest": { + "anyOf": [ + { + "$ref": "#/$defs/DiscoverRequest" + }, + { + "$ref": "#/$defs/ListResourcesRequest" + }, + { + "$ref": "#/$defs/ListResourceTemplatesRequest" + }, + { + "$ref": "#/$defs/ReadResourceRequest" + }, + { + "$ref": "#/$defs/SubscriptionsListenRequest" + }, + { + "$ref": "#/$defs/ListPromptsRequest" + }, + { + "$ref": "#/$defs/GetPromptRequest" + }, + { + "$ref": "#/$defs/ListToolsRequest" + }, + { + "$ref": "#/$defs/CallToolRequest" + }, + { + "$ref": "#/$defs/CompleteRequest" + } + ] + }, + "ClientResult": { + "$ref": "#/$defs/Result", + "description": "Common result fields." + }, + "CompleteRequest": { + "description": "A request from the client to the server, to ask for completion options.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "completion/complete", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CompleteRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CompleteRequestParams": { + "description": "Parameters for a `completion/complete` request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "argument": { + "description": "The argument's information", + "properties": { + "name": { + "description": "The name of the argument", + "type": "string" + }, + "value": { + "description": "The value of the argument to use for completion matching.", + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "type": "object" + }, + "context": { + "description": "Additional, optional context for completions", + "properties": { + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Previously-resolved variables in a URI template or prompt.", + "type": "object" + } + }, + "type": "object" + }, + "ref": { + "anyOf": [ + { + "$ref": "#/$defs/PromptReference" + }, + { + "$ref": "#/$defs/ResourceTemplateReference" + } + ] + } + }, + "required": [ + "_meta", + "argument", + "ref" + ], + "type": "object" + }, + "CompleteResult": { + "description": "The result returned by the server for a {@link CompleteRequestcompletion/complete} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "completion": { + "properties": { + "hasMore": { + "description": "Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown.", + "type": "boolean" + }, + "total": { + "description": "The total number of completion options available. This can exceed the number of values actually sent in the response.", + "type": "integer" + }, + "values": { + "description": "An array of completion values. Must not exceed 100 items.", + "items": { + "type": "string" + }, + "maxItems": 100, + "type": "array" + } + }, + "required": [ + "values" + ], + "type": "object" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "completion", + "resultType" + ], + "type": "object" + }, + "CompleteResultResponse": { + "description": "A successful response from the server for a {@link CompleteRequestcompletion/complete} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/CompleteResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ContentBlock": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ResourceLink" + }, + { + "$ref": "#/$defs/EmbeddedResource" + } + ] + }, + "CreateMessageRequest": { + "description": "A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it.", + "properties": { + "method": { + "const": "sampling/createMessage", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CreateMessageRequestParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "CreateMessageRequestParams": { + "description": "Parameters for a `sampling/createMessage` request.", + "properties": { + "includeContext": { + "description": "A request to include context from one or more MCP servers (including the caller), to be attached to the prompt.\nThe client MAY ignore this request.\n\nDefault is `\"none\"`. The values `\"thisServer\"` and `\"allServers\"` are deprecated (SEP-2596): servers SHOULD\nomit this field or use `\"none\"`, and SHOULD only use the deprecated values if the client declares\n{@link ClientCapabilities.sampling.context}.", + "enum": [ + "allServers", + "none", + "thisServer" + ], + "type": "string" + }, + "maxTokens": { + "description": "The requested maximum number of tokens to sample (to prevent runaway completions).\n\nThe client MAY choose to sample fewer tokens than the requested maximum.", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/$defs/SamplingMessage" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/$defs/JSONObject", + "description": "Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific." + }, + "modelPreferences": { + "$ref": "#/$defs/ModelPreferences", + "description": "The server's preferences for which model to select. The client MAY ignore these preferences." + }, + "stopSequences": { + "items": { + "type": "string" + }, + "type": "array" + }, + "systemPrompt": { + "description": "An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt.", + "type": "string" + }, + "temperature": { + "type": "number" + }, + "toolChoice": { + "$ref": "#/$defs/ToolChoice", + "description": "Controls how the model uses tools.\nThe client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared.\nDefault is `{ mode: \"auto\" }`." + }, + "tools": { + "description": "Tools that the model may use during generation.\nThe client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared.", + "items": { + "$ref": "#/$defs/Tool" + }, + "type": "array" + } + }, + "required": [ + "maxTokens", + "messages" + ], + "type": "object" + }, + "CreateMessageResult": { + "description": "The result returned by the client for a {@link CreateMessageRequestsampling/createMessage} request.\nThe client should inform the user before returning the sampled message, to allow them\nto inspect the response (human in the loop) and decide whether to allow the server to see it.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "content": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + }, + { + "items": { + "$ref": "#/$defs/SamplingMessageContentBlock" + }, + "type": "array" + } + ] + }, + "model": { + "description": "The name of the model that generated the message.", + "type": "string" + }, + "role": { + "$ref": "#/$defs/Role" + }, + "stopReason": { + "description": "The reason why sampling stopped, if known.\n\nStandard values:\n- `\"endTurn\"`: Natural end of the assistant's turn\n- `\"stopSequence\"`: A stop sequence was encountered\n- `\"maxTokens\"`: Maximum token limit was reached\n- `\"toolUse\"`: The model wants to use one or more tools\n\nThis field is an open string to allow for provider-specific stop reasons.", + "type": "string" + } + }, + "required": [ + "content", + "model", + "role" + ], + "type": "object" + }, + "Cursor": { + "description": "An opaque token used to represent a cursor for pagination.", + "type": "string" + }, + "DiscoverRequest": { + "description": "A request from the client asking the server to advertise its supported\nprotocol versions, capabilities, and other metadata. Servers **MUST**\nimplement `server/discover`. Clients **MAY** call it but are not required\nto — version negotiation can also happen inline via per-request `_meta`.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "server/discover", + "type": "string" + }, + "params": { + "$ref": "#/$defs/RequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "DiscoverResult": { + "description": "The result returned by the server for a {@link DiscoverRequestserver/discover} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: Any client or intermediary (e.g., shared gateway, proxy)\n MAY cache the response and serve it to any user.\n- `\"private\"`: Only the requesting user's client MAY cache the response.\n Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached\n copy to a different user.", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "capabilities": { + "$ref": "#/$defs/ServerCapabilities", + "description": "The capabilities of the server." + }, + "instructions": { + "description": "Natural-language guidance describing the server and its features.\n\nThis can be used by clients to improve an LLM's understanding of\navailable tools (e.g., by including it in a system prompt). It should\nfocus on information that helps the model use the server effectively\nand should not duplicate information already in tool descriptions.", + "type": "string" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "serverInfo": { + "$ref": "#/$defs/Implementation", + "description": "Information about the server software implementation." + }, + "supportedVersions": { + "description": "MCP Protocol Versions this server supports. The client should choose a\nversion from this list for use in subsequent requests.", + "items": { + "type": "string" + }, + "type": "array" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "capabilities", + "resultType", + "serverInfo", + "supportedVersions", + "ttlMs" + ], + "type": "object" + }, + "DiscoverResultResponse": { + "description": "A successful response from the server for a {@link DiscoverRequestserver/discover} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/DiscoverResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ElicitRequest": { + "description": "A request from the server to elicit additional information from the user via the client.", + "properties": { + "method": { + "const": "elicitation/create", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ElicitRequestParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "ElicitRequestFormParams": { + "description": "The parameters for a request to elicit non-sensitive information from the user via a form in the client.", + "properties": { + "message": { + "description": "The message to present to the user describing what information is being requested.", + "type": "string" + }, + "mode": { + "const": "form", + "description": "The elicitation mode.", + "type": "string" + }, + "requestedSchema": { + "description": "A restricted subset of JSON Schema.\nOnly top-level properties are allowed, without nesting.", + "properties": { + "$schema": { + "type": "string" + }, + "properties": { + "additionalProperties": { + "$ref": "#/$defs/PrimitiveSchemaDefinition" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "properties", + "type" + ], + "type": "object" + } + }, + "required": [ + "message", + "requestedSchema" + ], + "type": "object" + }, + "ElicitRequestParams": { + "anyOf": [ + { + "$ref": "#/$defs/ElicitRequestFormParams" + }, + { + "$ref": "#/$defs/ElicitRequestURLParams" + } + ], + "description": "The parameters for a request to elicit additional information from the user via the client." + }, + "ElicitRequestURLParams": { + "description": "The parameters for a request to elicit information from the user via a URL in the client.", + "properties": { + "elicitationId": { + "description": "The ID of the elicitation, which must be unique within the context of the server.\nThe client MUST treat this ID as an opaque value.", + "type": "string" + }, + "message": { + "description": "The message to present to the user explaining why the interaction is needed.", + "type": "string" + }, + "mode": { + "const": "url", + "description": "The elicitation mode.", + "type": "string" + }, + "url": { + "description": "The URL that the user should navigate to.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "elicitationId", + "message", + "mode", + "url" + ], + "type": "object" + }, + "ElicitResult": { + "description": "The result returned by the client for an {@link ElicitRequestelicitation/create} request.", + "properties": { + "action": { + "description": "The user action in response to the elicitation.\n- `\"accept\"`: User submitted the form/confirmed the action\n- `\"decline\"`: User explicitly declined the action\n- `\"cancel\"`: User dismissed without making an explicit choice", + "enum": [ + "accept", + "cancel", + "decline" + ], + "type": "string" + }, + "content": { + "additionalProperties": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "integer", + "boolean" + ] + } + ] + }, + "description": "The submitted form data, only present when action is `\"accept\"` and mode was `\"form\"`.\nContains values matching the requested schema.\nOmitted for out-of-band mode responses.", + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object" + }, + "ElicitationCompleteNotification": { + "description": "An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/elicitation/complete", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ElicitationCompleteNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ElicitationCompleteNotificationParams": { + "description": "Parameters for a {@link ElicitationCompleteNotificationnotifications/elicitation/complete} notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "elicitationId": { + "description": "The ID of the elicitation that completed.", + "type": "string" + } + }, + "required": [ + "elicitationId" + ], + "type": "object" + }, + "EmbeddedResource": { + "description": "The contents of a resource, embedded into a prompt or tool call result.\n\nIt is up to the client how best to render embedded resources for the benefit\nof the LLM and/or the user.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "resource": { + "anyOf": [ + { + "$ref": "#/$defs/TextResourceContents" + }, + { + "$ref": "#/$defs/BlobResourceContents" + } + ] + }, + "type": { + "const": "resource", + "type": "string" + } + }, + "required": [ + "resource", + "type" + ], + "type": "object" + }, + "EmptyResult": { + "$ref": "#/$defs/Result", + "description": "Common result fields." + }, + "EnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/LegacyTitledEnumSchema" + } + ] + }, + "Error": { + "properties": { + "code": { + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "GetPromptRequest": { + "description": "Used by the client to get a prompt provided by the server.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "prompts/get", + "type": "string" + }, + "params": { + "$ref": "#/$defs/GetPromptRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "GetPromptRequestParams": { + "description": "Parameters for a `prompts/get` request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Arguments to use for templating the prompt.", + "type": "object" + }, + "inputResponses": { + "$ref": "#/$defs/InputResponses" + }, + "name": { + "description": "The name of the prompt or prompt template.", + "type": "string" + }, + "requestState": { + "type": "string" + } + }, + "required": [ + "_meta", + "name" + ], + "type": "object" + }, + "GetPromptResult": { + "description": "The result returned by the server for a {@link GetPromptRequestprompts/get} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "description": { + "description": "An optional description for the prompt.", + "type": "string" + }, + "messages": { + "items": { + "$ref": "#/$defs/PromptMessage" + }, + "type": "array" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "messages", + "resultType" + ], + "type": "object" + }, + "GetPromptResultResponse": { + "description": "A successful response from the server for a {@link GetPromptRequestprompts/get} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "anyOf": [ + { + "$ref": "#/$defs/InputRequiredResult" + }, + { + "$ref": "#/$defs/GetPromptResult" + } + ] + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "Icon": { + "description": "An optionally-sized icon that can be displayed in a user interface.", + "properties": { + "mimeType": { + "description": "Optional MIME type override if the source MIME type is missing or generic.\nFor example: `\"image/png\"`, `\"image/jpeg\"`, or `\"image/svg+xml\"`.", + "type": "string" + }, + "sizes": { + "description": "Optional array of strings that specify sizes at which the icon can be used.\nEach string should be in WxH format (e.g., `\"48x48\"`, `\"96x96\"`) or `\"any\"` for scalable formats like SVG.\n\nIf not provided, the client should assume that the icon can be used at any size.", + "items": { + "type": "string" + }, + "type": "array" + }, + "src": { + "description": "A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a\n`data:` URI with Base64-encoded image data.\n\nConsumers SHOULD take steps to ensure URLs serving icons are from the\nsame domain as the client/server or a trusted domain.\n\nConsumers SHOULD take appropriate precautions when consuming SVGs as they can contain\nexecutable JavaScript.", + "format": "uri", + "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for. `\"light\"` indicates\nthe icon is designed to be used with a light background, and `\"dark\"` indicates\nthe icon is designed to be used with a dark background.\n\nIf not provided, the client should assume the icon can be used with any theme.", + "enum": [ + "dark", + "light" + ], + "type": "string" + } + }, + "required": [ + "src" + ], + "type": "object" + }, + "Icons": { + "description": "Base interface to add `icons` property.", + "properties": { + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + } + }, + "type": "object" + }, + "ImageContent": { + "description": "An image provided to or from an LLM.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded image data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image. Different providers may support different image types.", + "type": "string" + }, + "type": { + "const": "image", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "Implementation": { + "description": "Describes the MCP implementation.", + "properties": { + "description": { + "description": "An optional human-readable description of what this implementation does.\n\nThis can be used by clients or servers to provide context about their purpose\nand capabilities. For example, a server might describe the types of resources\nor tools it provides, while a client might describe its intended use case.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "version": { + "description": "The version of this implementation.", + "type": "string" + }, + "websiteUrl": { + "description": "An optional URL of the website for this implementation.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, + "InputRequest": { + "anyOf": [ + { + "$ref": "#/$defs/CreateMessageRequest" + }, + { + "$ref": "#/$defs/ListRootsRequest" + }, + { + "$ref": "#/$defs/ElicitRequest" + } + ] + }, + "InputRequests": { + "additionalProperties": { + "$ref": "#/$defs/InputRequest" + }, + "description": "A map of server-initiated requests that the client must fulfill.\nKeys are server-assigned identifiers; values are the request objects.", + "type": "object" + }, + "InputRequiredResult": { + "description": "An InputRequiredResult sent by the server to indicate that additional input is needed\nbefore the request can be completed.\n\nAt least one of `inputRequests` or `requestState` MUST be present.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "inputRequests": { + "$ref": "#/$defs/InputRequests" + }, + "requestState": { + "type": "string" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "resultType" + ], + "type": "object" + }, + "InputResponse": { + "anyOf": [ + { + "$ref": "#/$defs/CreateMessageResult" + }, + { + "$ref": "#/$defs/ListRootsResult" + }, + { + "$ref": "#/$defs/ElicitResult" + } + ] + }, + "InputResponseRequestParams": { + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "inputResponses": { + "$ref": "#/$defs/InputResponses" + }, + "requestState": { + "type": "string" + } + }, + "required": [ + "_meta" + ], + "type": "object" + }, + "InputResponses": { + "additionalProperties": { + "$ref": "#/$defs/InputResponse" + }, + "description": "A map of client responses to server-initiated requests.\nKeys correspond to the keys in the {@link InputRequests} map;\nvalues are the client's result for each request.", + "type": "object" + }, + "InternalError": { + "description": "A JSON-RPC error indicating that an internal error occurred on the receiver. This error is returned when the receiver encounters an unexpected condition that prevents it from fulfilling the request.", + "properties": { + "code": { + "const": -32603, + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "InvalidParamsError": { + "description": "A JSON-RPC error indicating that the method parameters are invalid or malformed.\n\nIn MCP, this error is returned in various contexts when request parameters fail validation:\n\n- **Tools**: Unknown tool name or invalid tool arguments\n- **Prompts**: Unknown prompt name or missing required arguments\n- **Pagination**: Invalid or expired cursor values\n- **Logging**: Invalid log level\n- **Elicitation**: Server requests an elicitation mode not declared in client capabilities\n- **Sampling**: Missing tool result or tool results mixed with other content", + "properties": { + "code": { + "const": -32602, + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "InvalidRequestError": { + "description": "A JSON-RPC error indicating that the request is not a valid request object. This error is returned when the message structure does not conform to the JSON-RPC 2.0 specification requirements for a request (e.g., missing required fields like `jsonrpc` or `method`, or using invalid types for these fields).", + "properties": { + "code": { + "const": -32600, + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "JSONArray": { + "items": { + "$ref": "#/$defs/JSONValue" + }, + "type": "array" + }, + "JSONObject": { + "additionalProperties": { + "$ref": "#/$defs/JSONValue" + }, + "type": "object" + }, + "JSONRPCErrorResponse": { + "description": "A response to a request that indicates an error occurred.", + "properties": { + "error": { + "$ref": "#/$defs/Error" + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "JSONRPCMessage": { + "anyOf": [ + { + "$ref": "#/$defs/JSONRPCRequest" + }, + { + "$ref": "#/$defs/JSONRPCNotification" + }, + { + "$ref": "#/$defs/JSONRPCResultResponse" + }, + { + "$ref": "#/$defs/JSONRPCErrorResponse" + } + ], + "description": "Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent." + }, + "JSONRPCNotification": { + "description": "A notification which does not expect a response.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCRequest": { + "description": "A request that expects a response.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCResponse": { + "anyOf": [ + { + "$ref": "#/$defs/JSONRPCResultResponse" + }, + { + "$ref": "#/$defs/JSONRPCErrorResponse" + } + ], + "description": "A response to a request, containing either the result or error." + }, + "JSONRPCResultResponse": { + "description": "A successful (non-error) response to a request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/Result" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "JSONValue": { + "anyOf": [ + { + "$ref": "#/$defs/JSONObject" + }, + { + "items": { + "$ref": "#/$defs/JSONValue" + }, + "type": "array" + }, + { + "type": [ + "string", + "integer", + "boolean" + ] + } + ] + }, + "LegacyTitledEnumSchema": { + "description": "Use {@link TitledSingleSelectEnumSchema} instead.\nThis interface will be removed in a future version.", + "properties": { + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "enumNames": { + "description": "(Legacy) Display names for enum values.\nNon-standard according to JSON schema 2020-12.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "ListPromptsRequest": { + "description": "Sent from the client to request a list of prompts and prompt templates the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "prompts/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ListPromptsResult": { + "description": "The result returned by the server for a {@link ListPromptsRequestprompts/list} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: Any client or intermediary (e.g., shared gateway, proxy)\n MAY cache the response and serve it to any user.\n- `\"private\"`: Only the requesting user's client MAY cache the response.\n Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached\n copy to a different user.", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "prompts": { + "items": { + "$ref": "#/$defs/Prompt" + }, + "type": "array" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "prompts", + "resultType", + "ttlMs" + ], + "type": "object" + }, + "ListPromptsResultResponse": { + "description": "A successful response from the server for a {@link ListPromptsRequestprompts/list} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/ListPromptsResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ListResourceTemplatesRequest": { + "description": "Sent from the client to request a list of resource templates the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/templates/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ListResourceTemplatesResult": { + "description": "The result returned by the server for a {@link ListResourceTemplatesRequestresources/templates/list} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: Any client or intermediary (e.g., shared gateway, proxy)\n MAY cache the response and serve it to any user.\n- `\"private\"`: Only the requesting user's client MAY cache the response.\n Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached\n copy to a different user.", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resourceTemplates": { + "items": { + "$ref": "#/$defs/ResourceTemplate" + }, + "type": "array" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "resourceTemplates", + "resultType", + "ttlMs" + ], + "type": "object" + }, + "ListResourceTemplatesResultResponse": { + "description": "A successful response from the server for a {@link ListResourceTemplatesRequestresources/templates/list} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/ListResourceTemplatesResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ListResourcesRequest": { + "description": "Sent from the client to request a list of resources the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ListResourcesResult": { + "description": "The result returned by the server for a {@link ListResourcesRequestresources/list} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: Any client or intermediary (e.g., shared gateway, proxy)\n MAY cache the response and serve it to any user.\n- `\"private\"`: Only the requesting user's client MAY cache the response.\n Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached\n copy to a different user.", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resources": { + "items": { + "$ref": "#/$defs/Resource" + }, + "type": "array" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "resources", + "resultType", + "ttlMs" + ], + "type": "object" + }, + "ListResourcesResultResponse": { + "description": "A successful response from the server for a {@link ListResourcesRequestresources/list} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/ListResourcesResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ListRootsRequest": { + "description": "Sent from the server to request a list of root URIs from the client. Roots allow\nservers to ask for specific directories or files to operate on. A common example\nfor roots is providing a set of repositories or directories a server should operate\non.\n\nThis request is typically used when the server needs to understand the file system\nstructure or access specific locations that the client has permission to read from.", + "properties": { + "method": { + "const": "roots/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/RequestParams" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "ListRootsResult": { + "description": "The result returned by the client for a {@link ListRootsRequestroots/list} request.\nThis result contains an array of {@link Root} objects, each representing a root directory\nor file that the server can operate on.", + "properties": { + "roots": { + "items": { + "$ref": "#/$defs/Root" + }, + "type": "array" + } + }, + "required": [ + "roots" + ], + "type": "object" + }, + "ListToolsRequest": { + "description": "Sent from the client to request a list of tools the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tools/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ListToolsResult": { + "description": "The result returned by the server for a {@link ListToolsRequesttools/list} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: Any client or intermediary (e.g., shared gateway, proxy)\n MAY cache the response and serve it to any user.\n- `\"private\"`: Only the requesting user's client MAY cache the response.\n Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached\n copy to a different user.", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "tools": { + "items": { + "$ref": "#/$defs/Tool" + }, + "type": "array" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "resultType", + "tools", + "ttlMs" + ], + "type": "object" + }, + "ListToolsResultResponse": { + "description": "A successful response from the server for a {@link ListToolsRequesttools/list} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/ListToolsResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "LoggingLevel": { + "description": "The severity of a log message.\n\nThese map to syslog message severities, as specified in RFC-5424:\nhttps://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1", + "enum": [ + "alert", + "critical", + "debug", + "emergency", + "error", + "info", + "notice", + "warning" + ], + "type": "string" + }, + "LoggingMessageNotification": { + "description": "JSONRPCNotification of a log message passed from server to client. The client opts in by setting `\"io.modelcontextprotocol/logLevel\"` in a request's `_meta`.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/message", + "type": "string" + }, + "params": { + "$ref": "#/$defs/LoggingMessageNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "LoggingMessageNotificationParams": { + "description": "Parameters for a `notifications/message` notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "data": { + "description": "The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here." + }, + "level": { + "$ref": "#/$defs/LoggingLevel", + "description": "The severity of this log message." + }, + "logger": { + "description": "An optional name of the logger issuing this message.", + "type": "string" + } + }, + "required": [ + "data", + "level" + ], + "type": "object" + }, + "MetaObject": { + "description": "Represents the contents of a `_meta` field, which clients and servers use to attach additional metadata to their interactions.\n\nCertain key names are reserved by MCP for protocol-level metadata; implementations MUST NOT make assumptions about values at these keys. Additionally, specific schema definitions may reserve particular names for purpose-specific metadata, as declared in those definitions.\n\nValid keys have two segments:\n\n**Prefix:**\n- Optional — if specified, MUST be a series of _labels_ separated by dots (`.`), followed by a slash (`/`).\n- Labels MUST start with a letter and end with a letter or digit. Interior characters may be letters, digits, or hyphens (`-`).\n- Implementations SHOULD use reverse DNS notation (e.g., `com.example/` rather than `example.com/`).\n- Any prefix where the second label is `modelcontextprotocol` or `mcp` is **reserved** for MCP use. For example: `io.modelcontextprotocol/`, `dev.mcp/`, `org.modelcontextprotocol.api/`, and `com.mcp.tools/` are all reserved. However, `com.example.mcp/` is NOT reserved, as the second label is `example`.\n\n**Name:**\n- Unless empty, MUST start and end with an alphanumeric character (`[a-z0-9A-Z]`).\n- Interior characters may be alphanumeric, hyphens (`-`), underscores (`_`), or dots (`.`).", + "type": "object" + }, + "MethodNotFoundError": { + "description": "A JSON-RPC error indicating that the requested method does not exist or is not available.\n\nIn MCP, a server returns this error when a client invokes a method the server does not implement — either a genuinely unknown method, or one gated behind a server capability the server did not advertise (e.g., calling `prompts/list` when the `prompts` capability was not advertised).\n\nA request that requires a client capability the client did not declare is signalled instead by {@link MissingRequiredClientCapabilityError} (`-32003`).", + "properties": { + "code": { + "const": -32601, + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "MissingRequiredClientCapabilityError": { + "description": "Returned when processing a request requires a capability the client did not\ndeclare in `clientCapabilities`. For HTTP, the response status code MUST be\n`400 Bad Request`.", + "properties": { + "error": { + "allOf": [ + { + "$ref": "#/$defs/Error" + }, + { + "properties": { + "code": { + "const": -32003, + "type": "integer" + }, + "data": { + "properties": { + "requiredCapabilities": { + "$ref": "#/$defs/ClientCapabilities", + "description": "The capabilities the server requires from the client to process this request." + } + }, + "required": [ + "requiredCapabilities" + ], + "type": "object" + } + }, + "required": [ + "code", + "data" + ], + "type": "object" + } + ] + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "ModelHint": { + "description": "Hints to use for model selection.\n\nKeys not declared here are currently left unspecified by the spec and are up\nto the client to interpret.", + "properties": { + "name": { + "description": "A hint for a model name.\n\nThe client SHOULD treat this as a substring of a model name; for example:\n - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022`\n - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc.\n - `claude` should match any Claude model\n\nThe client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example:\n - `gemini-1.5-flash` could match `claude-3-haiku-20240307`", + "type": "string" + } + }, + "type": "object" + }, + "ModelPreferences": { + "description": "The server's preferences for model selection, requested of the client during sampling.\n\nBecause LLMs can vary along multiple dimensions, choosing the \"best\" model is\nrarely straightforward. Different models excel in different areas—some are\nfaster but less capable, others are more capable but more expensive, and so\non. This interface allows servers to express their priorities across multiple\ndimensions to help clients make an appropriate selection for their use case.\n\nThese preferences are always advisory. The client MAY ignore them. It is also\nup to the client to decide how to interpret these preferences and how to\nbalance them against other considerations.", + "properties": { + "costPriority": { + "description": "How much to prioritize cost when selecting a model. A value of 0 means cost\nis not important, while a value of 1 means cost is the most important\nfactor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "hints": { + "description": "Optional hints to use for model selection.\n\nIf multiple hints are specified, the client MUST evaluate them in order\n(such that the first match is taken).\n\nThe client SHOULD prioritize these hints over the numeric priorities, but\nMAY still use the priorities to select from ambiguous matches.", + "items": { + "$ref": "#/$defs/ModelHint" + }, + "type": "array" + }, + "intelligencePriority": { + "description": "How much to prioritize intelligence and capabilities when selecting a\nmodel. A value of 0 means intelligence is not important, while a value of 1\nmeans intelligence is the most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "speedPriority": { + "description": "How much to prioritize sampling speed (latency) when selecting a model. A\nvalue of 0 means speed is not important, while a value of 1 means speed is\nthe most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "MultiSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + } + ] + }, + "Notification": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "NotificationParams": { + "description": "Common params for any notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + } + }, + "type": "object" + }, + "NumberSchema": { + "properties": { + "default": { + "type": "number" + }, + "description": { + "type": "string" + }, + "maximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "title": { + "type": "string" + }, + "type": { + "enum": [ + "integer", + "number" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "PaginatedRequest": { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "PaginatedRequestParams": { + "description": "Common params for paginated requests.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "required": [ + "_meta" + ], + "type": "object" + }, + "PaginatedResult": { + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "resultType" + ], + "type": "object" + }, + "ParseError": { + "description": "A JSON-RPC error indicating that invalid JSON was received by the server. This error is returned when the server cannot parse the JSON text of a message.", + "properties": { + "code": { + "const": -32700, + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "PrimitiveSchemaDefinition": { + "anyOf": [ + { + "$ref": "#/$defs/StringSchema" + }, + { + "$ref": "#/$defs/NumberSchema" + }, + { + "$ref": "#/$defs/BooleanSchema" + }, + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/LegacyTitledEnumSchema" + } + ], + "description": "Restricted schema definitions that only allow primitive types\nwithout nested objects or arrays." + }, + "ProgressNotification": { + "description": "An out-of-band notification used to inform the receiver of a progress update for a long-running request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/progress", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ProgressNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ProgressNotificationParams": { + "description": "Parameters for a {@link ProgressNotificationnotifications/progress} notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "message": { + "description": "An optional message describing the current progress.", + "type": "string" + }, + "progress": { + "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", + "type": "number" + }, + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "The progress token which was given in the initial request, used to associate this notification with the request that is proceeding." + }, + "total": { + "description": "Total number of items to process (or total progress required), if known.", + "type": "number" + } + }, + "required": [ + "progress", + "progressToken" + ], + "type": "object" + }, + "ProgressToken": { + "description": "A progress token, used to associate progress notifications with the original request.", + "type": [ + "string", + "integer" + ] + }, + "Prompt": { + "description": "A prompt or prompt template that the server offers.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "arguments": { + "description": "A list of arguments to use for templating the prompt.", + "items": { + "$ref": "#/$defs/PromptArgument" + }, + "type": "array" + }, + "description": { + "description": "An optional description of what this prompt provides", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptArgument": { + "description": "Describes an argument that a prompt can accept.", + "properties": { + "description": { + "description": "A human-readable description of the argument.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "required": { + "description": "Whether this argument must be provided.", + "type": "boolean" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/prompts/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "PromptMessage": { + "description": "Describes a message returned as part of a prompt.\n\nThis is similar to {@link SamplingMessage}, but also supports the embedding of\nresources from the MCP server.", + "properties": { + "content": { + "$ref": "#/$defs/ContentBlock" + }, + "role": { + "$ref": "#/$defs/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "PromptReference": { + "description": "Identifies a prompt.", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "type": { + "const": "ref/prompt", + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "type": "object" + }, + "ReadResourceRequest": { + "description": "Sent from the client to the server, to read a specific resource URI.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/read", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ReadResourceRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ReadResourceRequestParams": { + "description": "Parameters for a `resources/read` request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "inputResponses": { + "$ref": "#/$defs/InputResponses" + }, + "requestState": { + "type": "string" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "_meta", + "uri" + ], + "type": "object" + }, + "ReadResourceResult": { + "description": "The result returned by the server for a {@link ReadResourceRequestresources/read} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: Any client or intermediary (e.g., shared gateway, proxy)\n MAY cache the response and serve it to any user.\n- `\"private\"`: Only the requesting user's client MAY cache the response.\n Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached\n copy to a different user.", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "contents": { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/TextResourceContents" + }, + { + "$ref": "#/$defs/BlobResourceContents" + } + ] + }, + "type": "array" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "contents", + "resultType", + "ttlMs" + ], + "type": "object" + }, + "ReadResourceResultResponse": { + "description": "A successful response from the server for a {@link ReadResourceRequestresources/read} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "anyOf": [ + { + "$ref": "#/$defs/InputRequiredResult" + }, + { + "$ref": "#/$defs/ReadResourceResult" + } + ] + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "Request": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "RequestId": { + "description": "A uniquely identifying ID for a request in JSON-RPC.", + "type": [ + "string", + "integer" + ] + }, + "RequestMetaObject": { + "description": "Extends {@link MetaObject} with additional request-specific fields. All key naming rules from `MetaObject` apply.", + "properties": { + "io.modelcontextprotocol/clientCapabilities": { + "$ref": "#/$defs/ClientCapabilities", + "description": "The client's capabilities for this specific request. Required.\n\nCapabilities are declared per-request rather than once at initialization;\nan empty object means the client supports no optional capabilities.\nServers MUST NOT infer capabilities from prior requests." + }, + "io.modelcontextprotocol/clientInfo": { + "$ref": "#/$defs/Implementation", + "description": "Identifies the client software making the request. Required.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional." + }, + "io.modelcontextprotocol/logLevel": { + "$ref": "#/$defs/LoggingLevel", + "description": "The desired log level for this request. Optional.\n\nIf absent, the server MUST NOT send any {@link LoggingMessageNotificationnotifications/message}\nnotifications for this request. The client opts in to log messages by\nexplicitly setting a level. Replaces the former `logging/setLevel` RPC." + }, + "io.modelcontextprotocol/protocolVersion": { + "description": "The MCP Protocol Version being used for this request. Required.\n\nFor the HTTP transport, this value MUST match the `MCP-Protocol-Version`\nheader; otherwise the server MUST return a `400 Bad Request`. If the\nserver does not support the requested version, it MUST return an\n{@link UnsupportedProtocolVersionError}.", + "type": "string" + }, + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by {@link ProgressNotificationnotifications/progress}). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "required": [ + "io.modelcontextprotocol/clientCapabilities", + "io.modelcontextprotocol/clientInfo", + "io.modelcontextprotocol/protocolVersion" + ], + "type": "object" + }, + "RequestParams": { + "description": "Common params for any request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + } + }, + "required": [ + "_meta" + ], + "type": "object" + }, + "Resource": { + "description": "A known resource that the server is capable of reading.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "uri" + ], + "type": "object" + }, + "ResourceContents": { + "description": "The contents of a specific resource or sub-resource.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "ResourceLink": { + "description": "A resource that the server is capable of reading, included in a prompt or tool call result.\n\nNote: resource links returned by tools are not guaranteed to appear in the results of {@link ListResourcesRequestresources/list} requests.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "type": { + "const": "resource_link", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "type", + "uri" + ], + "type": "object" + }, + "ResourceListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/resources/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "ResourceRequestParams": { + "description": "Common params for resource-related requests.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "_meta", + "uri" + ], + "type": "object" + }, + "ResourceTemplate": { + "description": "A template description for resources available on the server.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this template is for.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "uriTemplate": { + "description": "A URI template (according to RFC 6570) that can be used to construct resource URIs.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "name", + "uriTemplate" + ], + "type": "object" + }, + "ResourceTemplateReference": { + "description": "A reference to a resource or resource template definition.", + "properties": { + "type": { + "const": "ref/resource", + "type": "string" + }, + "uri": { + "description": "The URI or URI template of the resource.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "type", + "uri" + ], + "type": "object" + }, + "ResourceUpdatedNotification": { + "description": "A notification from the server to the client, informing it that a resource has changed and may need to be read again. This is only sent for resources the client opted in to via the `resourceSubscriptions` field of a {@link SubscriptionsListenRequestsubscriptions/listen} request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/resources/updated", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ResourceUpdatedNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ResourceUpdatedNotificationParams": { + "description": "Parameters for a `notifications/resources/updated` notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "uri": { + "description": "The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "Result": { + "additionalProperties": {}, + "description": "Common result fields.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "resultType" + ], + "type": "object" + }, + "ResultType": { + "description": "Indicates the type of a {@link Result} object, allowing the client to\ndetermine how to parse the response.\n\ncomplete - the request completed successfully and the result contains the final content.\ninput_required - the request requires additional input and the result contains an {@link InputRequiredResult} object with instructions for the client to provide additional input before retrying the original request.", + "type": "string" + }, + "Role": { + "description": "The sender or recipient of messages and data in a conversation.", + "enum": [ + "assistant", + "user" + ], + "type": "string" + }, + "Root": { + "description": "Represents a root directory or file that the server can operate on.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "name": { + "description": "An optional name for the root. This can be used to provide a human-readable\nidentifier for the root, which may be useful for display purposes or for\nreferencing the root in other parts of the application.", + "type": "string" + }, + "uri": { + "description": "The URI identifying the root. This *must* start with `file://` for now.\nThis restriction may be relaxed in future versions of the protocol to allow\nother URI schemes.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "SamplingMessage": { + "description": "Describes a message issued to or received from an LLM API.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "content": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + }, + { + "items": { + "$ref": "#/$defs/SamplingMessageContentBlock" + }, + "type": "array" + } + ] + }, + "role": { + "$ref": "#/$defs/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "SamplingMessageContentBlock": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + } + ] + }, + "ServerCapabilities": { + "description": "Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities.", + "properties": { + "completions": { + "$ref": "#/$defs/JSONObject", + "description": "Present if the server supports argument autocompletion suggestions." + }, + "experimental": { + "additionalProperties": { + "$ref": "#/$defs/JSONObject" + }, + "description": "Experimental, non-standard capabilities that the server supports.", + "type": "object" + }, + "extensions": { + "additionalProperties": { + "$ref": "#/$defs/JSONObject" + }, + "description": "Optional MCP extensions that the server supports. Keys are extension identifiers\n(e.g., \"io.modelcontextprotocol/tasks\"), and values are per-extension settings\nobjects. An empty object indicates support with no settings.\n\nKeys MUST follow the {@link MetaObject`_meta` key naming rules}, with a\nmandatory prefix.", + "type": "object" + }, + "logging": { + "$ref": "#/$defs/JSONObject", + "description": "Present if the server supports sending log messages to the client." + }, + "prompts": { + "description": "Present if the server offers any prompt templates.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the prompt list.", + "type": "boolean" + } + }, + "type": "object" + }, + "resources": { + "description": "Present if the server offers any resources to read.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the resource list.", + "type": "boolean" + }, + "subscribe": { + "description": "Whether this server supports subscribing to resource updates.", + "type": "boolean" + } + }, + "type": "object" + }, + "tools": { + "description": "Present if the server offers any tools to call.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the tool list.", + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ServerNotification": { + "anyOf": [ + { + "$ref": "#/$defs/CancelledNotification" + }, + { + "$ref": "#/$defs/ProgressNotification" + }, + { + "$ref": "#/$defs/ResourceListChangedNotification" + }, + { + "$ref": "#/$defs/SubscriptionsAcknowledgedNotification" + }, + { + "$ref": "#/$defs/ResourceUpdatedNotification" + }, + { + "$ref": "#/$defs/PromptListChangedNotification" + }, + { + "$ref": "#/$defs/ToolListChangedNotification" + }, + { + "$ref": "#/$defs/LoggingMessageNotification" + }, + { + "$ref": "#/$defs/ElicitationCompleteNotification" + } + ] + }, + "ServerResult": { + "anyOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/InputRequiredResult" + }, + { + "$ref": "#/$defs/DiscoverResult" + }, + { + "$ref": "#/$defs/ListResourcesResult" + }, + { + "$ref": "#/$defs/ListResourceTemplatesResult" + }, + { + "$ref": "#/$defs/ReadResourceResult" + }, + { + "$ref": "#/$defs/ListPromptsResult" + }, + { + "$ref": "#/$defs/GetPromptResult" + }, + { + "$ref": "#/$defs/ListToolsResult" + }, + { + "$ref": "#/$defs/CallToolResult" + }, + { + "$ref": "#/$defs/CompleteResult" + } + ] + }, + "SingleSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + } + ] + }, + "StringSchema": { + "properties": { + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "format": { + "enum": [ + "date", + "date-time", + "email", + "uri" + ], + "type": "string" + }, + "maxLength": { + "type": "integer" + }, + "minLength": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "SubscriptionFilter": { + "description": "The set of notification types a client may opt in to on a\n{@link SubscriptionsListenRequestsubscriptions/listen} request.\n\nEach notification type is **opt-in**; the server **MUST NOT** send\nnotification types the client has not explicitly requested here.", + "properties": { + "promptsListChanged": { + "description": "If true, receive {@link PromptListChangedNotificationnotifications/prompts/list_changed}.", + "type": "boolean" + }, + "resourceSubscriptions": { + "description": "Subscribe to {@link ResourceUpdatedNotificationnotifications/resources/updated} for these resource URIs.\nReplaces the former `resources/subscribe` RPC.", + "items": { + "type": "string" + }, + "type": "array" + }, + "resourcesListChanged": { + "description": "If true, receive {@link ResourceListChangedNotificationnotifications/resources/list_changed}.", + "type": "boolean" + }, + "toolsListChanged": { + "description": "If true, receive {@link ToolListChangedNotificationnotifications/tools/list_changed}.", + "type": "boolean" + } + }, + "type": "object" + }, + "SubscriptionsAcknowledgedNotification": { + "description": "Sent by the server as the first message on a\n{@link SubscriptionsListenRequestsubscriptions/listen} stream to acknowledge\nthat the subscription has been established and to report which notification\ntypes it agreed to honor.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/subscriptions/acknowledged", + "type": "string" + }, + "params": { + "$ref": "#/$defs/SubscriptionsAcknowledgedNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "SubscriptionsAcknowledgedNotificationParams": { + "description": "Parameters for a {@link SubscriptionsAcknowledgedNotificationnotifications/subscriptions/acknowledged} notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "notifications": { + "$ref": "#/$defs/SubscriptionFilter", + "description": "The subset of requested notification types the server agreed to honor.\nOnly includes notification types the server actually supports; if the\nclient requested an unsupported type (e.g., `promptsListChanged` when\nthe server has no prompts), it is omitted from this set." + } + }, + "required": [ + "notifications" + ], + "type": "object" + }, + "SubscriptionsListenRequest": { + "description": "Sent from the client to open a long-lived channel for receiving notifications\noutside the context of a specific request. Replaces the previous HTTP GET\nendpoint and ensures consistent behavior between HTTP and STDIO.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "subscriptions/listen", + "type": "string" + }, + "params": { + "$ref": "#/$defs/SubscriptionsListenRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "SubscriptionsListenRequestParams": { + "description": "Parameters for a {@link SubscriptionsListenRequestsubscriptions/listen} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "notifications": { + "$ref": "#/$defs/SubscriptionFilter", + "description": "The notifications the client opts in to on this stream. The server\n**MUST NOT** send notification types the client has not explicitly\nrequested." + } + }, + "required": [ + "_meta", + "notifications" + ], + "type": "object" + }, + "TextContent": { + "description": "Text provided to or from an LLM.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "text": { + "description": "The text content of the message.", + "type": "string" + }, + "type": { + "const": "text", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "type": "object" + }, + "TextResourceContents": { + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "text": { + "description": "The text of the item. This must only be set if the item can actually be represented as text (not binary data).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "text", + "uri" + ], + "type": "object" + }, + "TitledMultiSelectEnumSchema": { + "description": "Schema for multiple-selection enumeration with display titles for each option.", + "properties": { + "default": { + "description": "Optional default value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "items": { + "description": "Schema for array items with enum options and display labels.", + "properties": { + "anyOf": { + "description": "Array of enum options with values and display labels.", + "items": { + "properties": { + "const": { + "description": "The constant enum value.", + "type": "string" + }, + "title": { + "description": "Display title for this option.", + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "anyOf" + ], + "type": "object" + }, + "maxItems": { + "description": "Maximum number of items to select.", + "type": "integer" + }, + "minItems": { + "description": "Minimum number of items to select.", + "type": "integer" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "array", + "type": "string" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "TitledSingleSelectEnumSchema": { + "description": "Schema for single-selection enumeration with display titles for each option.", + "properties": { + "default": { + "description": "Optional default value.", + "type": "string" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "oneOf": { + "description": "Array of enum options with values and display labels.", + "items": { + "properties": { + "const": { + "description": "The enum value.", + "type": "string" + }, + "title": { + "description": "Display label for this option.", + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "type": "array" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "oneOf", + "type" + ], + "type": "object" + }, + "Tool": { + "description": "Definition for a tool the client can call.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/ToolAnnotations", + "description": "Optional additional tool information.\n\nDisplay name precedence order is: `title`, `annotations.title`, then `name`." + }, + "description": { + "description": "A human-readable description of the tool.\n\nThis can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "inputSchema": { + "additionalProperties": {}, + "description": "A JSON Schema object defining the expected parameters for the tool.\n\nTool arguments are always JSON objects, so `type: \"object\"` is required at the root.\nBeyond that, any JSON Schema 2020-12 keyword may appear alongside `type` — including\ncomposition keywords (`oneOf`, `anyOf`, `allOf`, `not`), conditional keywords\n(`if`/`then`/`else`), reference keywords (`$ref`, `$defs`, `$anchor`), and any other\nstandard validation or annotation keywords.\n\nDefaults to JSON Schema 2020-12 when no explicit `$schema` is provided.", + "properties": { + "$schema": { + "type": "string" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "outputSchema": { + "additionalProperties": {}, + "description": "An optional JSON Schema object defining the structure of the tool's output returned in\nthe structuredContent field of a {@link CallToolResult}. This can be any valid JSON Schema 2020-12.\n\nDefaults to JSON Schema 2020-12 when no explicit `$schema` is provided.", + "properties": { + "$schema": { + "type": "string" + } + }, + "type": "object" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "inputSchema", + "name" + ], + "type": "object" + }, + "ToolAnnotations": { + "description": "Additional properties describing a {@link Tool} to clients.\n\nNOTE: all properties in `ToolAnnotations` are **hints**.\nThey are not guaranteed to provide a faithful description of\ntool behavior (including descriptive properties like `title`).\n\nClients should never make tool use decisions based on `ToolAnnotations`\nreceived from untrusted servers.", + "properties": { + "destructiveHint": { + "description": "If true, the tool may perform destructive updates to its environment.\nIf false, the tool performs only additive updates.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: true", + "type": "boolean" + }, + "idempotentHint": { + "description": "If true, calling the tool repeatedly with the same arguments\nwill have no additional effect on its environment.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: false", + "type": "boolean" + }, + "openWorldHint": { + "description": "If true, this tool may interact with an \"open world\" of external\nentities. If false, the tool's domain of interaction is closed.\nFor example, the world of a web search tool is open, whereas that\nof a memory tool is not.\n\nDefault: true", + "type": "boolean" + }, + "readOnlyHint": { + "description": "If true, the tool does not modify its environment.\n\nDefault: false", + "type": "boolean" + }, + "title": { + "description": "A human-readable title for the tool.", + "type": "string" + } + }, + "type": "object" + }, + "ToolChoice": { + "description": "Controls tool selection behavior for sampling requests.", + "properties": { + "mode": { + "description": "Controls the tool use ability of the model:\n- `\"auto\"`: Model decides whether to use tools (default)\n- `\"required\"`: Model MUST use at least one tool before completing\n- `\"none\"`: Model MUST NOT use any tools", + "enum": [ + "auto", + "none", + "required" + ], + "type": "string" + } + }, + "type": "object" + }, + "ToolListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/tools/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "ToolResultContent": { + "description": "The result of a tool use, provided by the user back to the assistant.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject", + "description": "Optional metadata about the tool result. Clients SHOULD preserve this field when\nincluding tool results in subsequent sampling requests to enable caching optimizations." + }, + "content": { + "description": "The unstructured result content of the tool use.\n\nThis has the same format as {@link CallToolResult.content} and can include text, images,\naudio, resource links, and embedded resources.", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool use resulted in an error.\n\nIf true, the content typically describes the error that occurred.\nDefault: false", + "type": "boolean" + }, + "structuredContent": { + "description": "An optional structured result value.\n\nThis can be any JSON value (object, array, string, number, boolean, or null).\nIf the tool defined an {@link Tool.outputSchema}, this SHOULD conform to that schema." + }, + "toolUseId": { + "description": "The ID of the tool use this result corresponds to.\n\nThis MUST match the ID from a previous {@link ToolUseContent}.", + "type": "string" + }, + "type": { + "const": "tool_result", + "type": "string" + } + }, + "required": [ + "content", + "toolUseId", + "type" + ], + "type": "object" + }, + "ToolUseContent": { + "description": "A request from the assistant to call a tool.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject", + "description": "Optional metadata about the tool use. Clients SHOULD preserve this field when\nincluding tool uses in subsequent sampling requests to enable caching optimizations." + }, + "id": { + "description": "A unique identifier for this tool use.\n\nThis ID is used to match tool results to their corresponding tool uses.", + "type": "string" + }, + "input": { + "additionalProperties": {}, + "description": "The arguments to pass to the tool, conforming to the tool's input schema.", + "type": "object" + }, + "name": { + "description": "The name of the tool to call.", + "type": "string" + }, + "type": { + "const": "tool_use", + "type": "string" + } + }, + "required": [ + "id", + "input", + "name", + "type" + ], + "type": "object" + }, + "UnsupportedProtocolVersionError": { + "description": "Returned when the request's protocol version is unknown to the server or\nunsupported (e.g., a known experimental or draft version the server has\nchosen not to implement). For HTTP, the response status code MUST be\n`400 Bad Request`.", + "properties": { + "error": { + "allOf": [ + { + "$ref": "#/$defs/Error" + }, + { + "properties": { + "code": { + "const": -32004, + "type": "integer" + }, + "data": { + "properties": { + "requested": { + "description": "The protocol version that was requested by the client.", + "type": "string" + }, + "supported": { + "description": "Protocol versions the server supports. The client should choose a\nmutually supported version from this list and retry.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "requested", + "supported" + ], + "type": "object" + } + }, + "required": [ + "code", + "data" + ], + "type": "object" + } + ] + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "UntitledMultiSelectEnumSchema": { + "description": "Schema for multiple-selection enumeration without display titles for options.", + "properties": { + "default": { + "description": "Optional default value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "items": { + "description": "Schema for the array items.", + "properties": { + "enum": { + "description": "Array of enum values to choose from.", + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "maxItems": { + "description": "Maximum number of items to select.", + "type": "integer" + }, + "minItems": { + "description": "Minimum number of items to select.", + "type": "integer" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "array", + "type": "string" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "UntitledSingleSelectEnumSchema": { + "description": "Schema for single-selection enumeration without display titles for options.", + "properties": { + "default": { + "description": "Optional default value.", + "type": "string" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "enum": { + "description": "Array of enum values to choose from.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + } + } +} + diff --git a/packages/core/test/corpus/schema-twins/manifest.json b/packages/core/test/corpus/schema-twins/manifest.json new file mode 100644 index 0000000000..1b21d36b3d --- /dev/null +++ b/packages/core/test/corpus/schema-twins/manifest.json @@ -0,0 +1,19 @@ +{ + "comment": "Vendored schema.json twins (TEST-ONLY conformance oracles; never bundled, never runtime). RAW upstream bytes - never reformat: each file is locked to the sha256/bytes below by schemaTwinConformance. Refresh via `pnpm fetch:schema-twins [sha]`, ATOMICALLY with the matching spec.types anchor (see packages/core/src/types/README.md lifecycle rule 4).", + "source": { + "repository": "modelcontextprotocol/modelcontextprotocol", + "commit": "0168c57fc74aba6e6dcf8f0b7191db3caaa5ad65" + }, + "files": { + "2026-07-28": { + "sha256": "afaf886c06dd8d3cbdd556d81b6483b9018112aaf7ee284fa116eca58baf54fc", + "bytes": 172822, + "upstreamPath": "schema/draft/schema.json" + }, + "2025-11-25": { + "sha256": "7b2d96fd95efd2216aa953606b83f5a740ddeaa5ebd3a5d27b45a8296545a118", + "bytes": 174326, + "upstreamPath": "schema/2025-11-25/schema.json" + } + } +} diff --git a/packages/core/test/corpus/specCorpus.test.ts b/packages/core/test/corpus/specCorpus.test.ts new file mode 100644 index 0000000000..f36586f9e7 --- /dev/null +++ b/packages/core/test/corpus/specCorpus.test.ts @@ -0,0 +1,202 @@ +/** + * Spec example corpus — accept-side fixtures parsed through the SDK's wire schemas. + * + * Two corpora, one harness: + * + * - `fixtures/2026-07-28/` is VENDORED from the spec repository's draft + * example set (`schema/draft/examples/`), regenerated only via + * `pnpm fetch:spec-examples` (provenance in its manifest.json). Every + * example directory is named after a spec type; each file is a canonical + * instance of that type. + * - `fixtures/2025-11-25/` is HAND-BUILT and FROZEN: upstream ships no + * example corpus for the released 2025-11-25 revision, so these fixtures + * pin representative 2025-era wire shapes (including the task wire surface + * that revision defines). Do not edit them casually — they are the + * accept-side net for any future change to how 2025-era traffic parses. + * + * Directory-name → schema mapping is mechanical (`Schema`), with two + * structural exceptions (JSON-RPC response envelopes and bare error objects) + * and an explicit pending list for draft vocabulary the SDK does not model + * yet. The pending list is stale-checked in both directions: a pending entry + * whose schema appears must be removed, and an unmapped directory that is not + * pending fails loudly — no silent skips. + * + * Rejection-side fixtures are deliberately NOT here: accept-only corpora are + * blind to accept→reject deltas, so rejections are routed through real + * dispatch in specCorpusDispatch.test.ts. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; + +import { + CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, + JSONRPCErrorResponseSchema, + JSONRPCResultResponseSchema +} from '../../src/types/schemas.js'; +import * as schemas from '../../src/types/schemas.js'; +// Era routing (Q1 increment 2): each corpus revision resolves through its own +// wire-era module first — 2025 fixtures may use 2025-only vocabulary (tasks), +// 2026 fixtures use 2026-only vocabulary (envelope, discover) — then falls +// back to the shared neutral payload schemas. +import * as wire2025 from '../../src/wire/rev2025-11-25/schemas.js'; +import * as wire2026 from '../../src/wire/rev2026-07-28/schemas.js'; + +const FIXTURES_ROOT = join(__dirname, 'fixtures'); + +/** JSON-RPC error-object example directories (bare `{code, message, data?}` shapes). */ +const ERROR_OBJECT_DIRS = new Set([ + 'InternalError', + 'InvalidParamsError', + 'MethodNotFoundError', + 'MissingRequiredClientCapabilityError', + 'ParseError', + 'UnsupportedProtocolVersionError' +]); + +/** + * Draft (2026-07-28) vocabulary the SDK does not model yet, at directory + * granularity. Each entry names the reason; the harness asserts the schema is + * genuinely absent so a stale entry (vocabulary landed but still listed) + * fails loudly. These burn down as the corresponding features land. + */ +const PENDING_2026: Record = { + InputRequests: 'multi-round-trip request vocabulary (SEP-2322) is not modeled yet', + InputRequiredResult: 'multi-round-trip request vocabulary (SEP-2322) is not modeled yet', + InputResponses: 'multi-round-trip request vocabulary (SEP-2322) is not modeled yet', + SubscriptionsAcknowledgedNotification: 'subscriptions/listen vocabulary (SEP-1865) is not modeled yet', + SubscriptionsListenRequest: 'subscriptions/listen vocabulary (SEP-1865) is not modeled yet' +}; + +/** + * Individual draft examples whose vocabulary the SDK does not accept yet + * (file granularity — the directory's schema exists but this instance uses a + * draft-only widening). Stale-checked: each listed file must actually FAIL to + * parse, so the entry is removed the moment the widening lands. + */ +const PENDING_2026_FILES: Record = { + // (empty — the SEP-2549 array-shape widenings burned when the 2026-era + // wire module landed anchor-exact Tool/CallToolResult forks; the two + // examples are real pins now.) +}; + +type AnyZod = z.ZodType; + +const ERA_SCHEMAS: Record> = { + '2025-11-25': wire2025 as Record, + '2026-07-28': wire2026 as Record +}; + +function schemaFor(revision: string, dir: string, fixture: unknown): AnyZod | undefined { + if (ERROR_OBJECT_DIRS.has(dir)) { + // The upstream error examples mix bare `{code, message, data?}` objects + // with full JSON-RPC error responses — pick by shape. + const isEnveloped = typeof fixture === 'object' && fixture !== null && 'jsonrpc' in fixture; + return isEnveloped ? (JSONRPCErrorResponseSchema as AnyZod) : (JSONRPCErrorResponseSchema.shape.error as AnyZod); + } + if (dir.endsWith('ResultResponse')) return JSONRPCResultResponseSchema as AnyZod; + if (dir === 'CreateMessageResult') { + // The SDK models this spec type as two schemas (single-content and + // tool-use array content); an example instance may be either. + return z.union([CreateMessageResultSchema, CreateMessageResultWithToolsSchema]) as AnyZod; + } + const eraSchema = ERA_SCHEMAS[revision]?.[`${dir}Schema`]; + if (eraSchema !== undefined) return eraSchema as AnyZod; + return (schemas as Record)[`${dir}Schema`] as AnyZod | undefined; +} + +function listTypeDirs(revision: string): string[] { + const root = join(FIXTURES_ROOT, revision); + return readdirSync(root) + .filter(entry => statSync(join(root, entry)).isDirectory()) + .sort(); +} + +function listFixtures(revision: string, dir: string): string[] { + return readdirSync(join(FIXTURES_ROOT, revision, dir)) + .filter(file => file.endsWith('.json')) + .sort(); +} + +function loadFixture(revision: string, dir: string, file: string): unknown { + return JSON.parse(readFileSync(join(FIXTURES_ROOT, revision, dir, file), 'utf8')); +} + +describe.each(['2025-11-25', '2026-07-28'] as const)('spec example corpus %s', revision => { + const typeDirs = listTypeDirs(revision); + const pending = revision === '2026-07-28' ? PENDING_2026 : {}; + + const pendingFiles = revision === '2026-07-28' ? PENDING_2026_FILES : {}; + + test('every example directory is mapped to a schema or explicitly pending', () => { + const unmapped = typeDirs.filter(dir => !(dir in pending) && schemaFor(revision, dir, {}) === undefined); + expect(unmapped, 'unmapped example directories — map them or add a documented pending entry').toEqual([]); + }); + + test('pending entries are not stale (their vocabulary is still unmodeled)', () => { + const stale = Object.keys(pending).filter(dir => schemaFor(revision, dir, {}) !== undefined); + expect(stale, 'pending entries whose schema now exists — wire the fixtures and remove the entry').toEqual([]); + // Pending entries must refer to directories that actually exist. + const missing = Object.keys(pending).filter(dir => !typeDirs.includes(dir)); + expect(missing, 'pending entries without a fixture directory').toEqual([]); + + const missingFiles = Object.keys(pendingFiles).filter(relPath => { + const [dir, file] = relPath.split('/'); + if (dir === undefined || file === undefined) return true; + return !typeDirs.includes(dir) || !listFixtures(revision, dir).includes(file); + }); + expect(missingFiles, 'pending file entries without a fixture file').toEqual([]); + }); + + const mappedDirs = typeDirs.filter(dir => !(dir in pending)); + describe.each(mappedDirs)('%s', dir => { + test.each(listFixtures(revision, dir))('%s parses', file => { + const fixture = loadFixture(revision, dir, file); + const schema = schemaFor(revision, dir, fixture); + expect(schema).toBeDefined(); + const parsed = schema!.safeParse(fixture); + const pendingReason = pendingFiles[`${dir}/${file}`]; + if (pendingReason !== undefined) { + // Stale-check: a pending file that parses means the widening + // landed — remove the entry so the example becomes a real pin. + expect(parsed.success, `pending entry is stale ('${dir}/${file}' now parses): ${pendingReason}`).toBe(false); + return; + } + expect(parsed.success, parsed.success ? undefined : `'${dir}/${file}' failed to parse:\n${parsed.error}`).toBe(true); + }); + }); +}); + +describe('corpus inventory pins', () => { + test('the vendored 2026-07-28 corpus matches its manifest (provenance + drift pin)', () => { + const manifest = JSON.parse(readFileSync(join(FIXTURES_ROOT, '2026-07-28', 'manifest.json'), 'utf8')) as { + revision: string; + source: { commit: string }; + directoryCount: number; + fileCount: number; + directories: Record; + }; + expect(manifest.revision).toBe('2026-07-28'); + + const dirs = listTypeDirs('2026-07-28'); + expect(dirs).toEqual(Object.keys(manifest.directories).sort()); + const fileCount = dirs.reduce((sum, dir) => sum + listFixtures('2026-07-28', dir).length, 0); + expect(fileCount).toBe(manifest.fileCount); + + // The corpus size at the pinned spec commit. A change here means the + // vendored corpus was regenerated — review the delta deliberately. + expect(manifest.directoryCount).toBe(86); + expect(manifest.fileCount).toBe(127); + }); + + test('the frozen 2025-11-25 corpus keeps its inventory', () => { + const dirs = listTypeDirs('2025-11-25'); + const fileCount = dirs.reduce((sum, dir) => sum + listFixtures('2025-11-25', dir).length, 0); + // Hand-built and frozen: growing it is welcome (raise the pin in the + // same change); silent shrinkage is not. + expect(fileCount).toBe(47); + }); +}); diff --git a/packages/core/test/corpus/specCorpusDispatch.test.ts b/packages/core/test/corpus/specCorpusDispatch.test.ts new file mode 100644 index 0000000000..88859aa71a --- /dev/null +++ b/packages/core/test/corpus/specCorpusDispatch.test.ts @@ -0,0 +1,121 @@ +/** + * Rejection-side corpus, routed through real dispatch. + * + * Accept-only corpora (specCorpus.test.ts) are blind to accept→reject deltas: + * a schema split or strictness change that turns previously-accepted traffic + * into rejections (or vice versa) never fails a parse-success fixture. These + * fixtures therefore drive raw JSON-RPC messages through a connected + * Protocol — the transport boundary, classification, handler lookup, and + * per-method parse exactly as production dispatch runs them — and pin the + * observable outcome of each: + * + * - `error-response`: an error response with the pinned code is sent back + * - `onerror`: no response; the failure surfaces via onerror + * - `ignored`: no response and no onerror (silent drop) + * - `result-response`: a result response is sent (accept-side sanity) + * + * The fixtures record TODAY's dispatch behavior. When a deliberate change + * moves the accept/reject line, the affected fixture turns red and must be + * updated in the same change (with its changeset / migration entry). + */ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, test } from 'vitest'; + +import { Protocol } from '../../src/shared/protocol.js'; +import type { BaseContext } from '../../src/shared/protocol.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; +import type { JSONRPCMessage } from '../../src/types/index.js'; + +const REJECTION_DIR = join(__dirname, 'fixtures', 'rejection'); + +interface DispatchFixture { + description: string; + message: unknown; + expect: 'error-response' | 'onerror' | 'ignored' | 'result-response'; + errorCode?: number; +} + +class ReceiverProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +interface Outcome { + responses: JSONRPCMessage[]; + errors: Error[]; +} + +/** Connect a receiver, inject the raw message from the peer side, observe. */ +async function dispatch(message: unknown): Promise { + const [peerTx, receiverTx] = InMemoryTransport.createLinkedPair(); + + const receiver = new ReceiverProtocol(); + const errors: Error[] = []; + receiver.onerror = error => void errors.push(error); + // One registered spec handler so the accept-side fixture has a target. + receiver.setRequestHandler('tools/call', async request => ({ + content: [{ type: 'text', text: String(request.params?.name) }] + })); + await receiver.connect(receiverTx); + + const responses: JSONRPCMessage[] = []; + peerTx.onmessage = received => void responses.push(received); + await peerTx.start(); + + // The InMemoryTransport is typed for valid messages; the cast is the + // point — raw bytes can always carry these shapes to dispatch. + await peerTx.send(message as JSONRPCMessage); + + // Dispatch is asynchronous (handlers run in promise chains); settle. + await new Promise(resolve => setTimeout(resolve, 25)); + + await receiver.close(); + return { responses, errors }; +} + +const fixtureFiles = readdirSync(REJECTION_DIR) + .filter(file => file.endsWith('.json')) + .sort(); + +describe('dispatch-routed corpus (rejection side + accept sanity)', () => { + test('the corpus is present', () => { + expect(fixtureFiles.length).toBeGreaterThanOrEqual(13); + }); + + test.each(fixtureFiles)('%s', async file => { + const fixture = JSON.parse(readFileSync(join(REJECTION_DIR, file), 'utf8')) as DispatchFixture; + const outcome = await dispatch(fixture.message); + + switch (fixture.expect) { + case 'error-response': { + expect(outcome.responses, fixture.description).toHaveLength(1); + const response = outcome.responses[0] as { error?: { code: number } }; + expect(response.error, `expected an error response: ${fixture.description}`).toBeDefined(); + expect(response.error?.code, fixture.description).toBe(fixture.errorCode); + break; + } + case 'result-response': { + expect(outcome.responses, fixture.description).toHaveLength(1); + const response = outcome.responses[0] as { result?: unknown }; + expect(response.result, `expected a result response: ${fixture.description}`).toBeDefined(); + break; + } + case 'onerror': { + expect(outcome.responses, `expected no response: ${fixture.description}`).toHaveLength(0); + expect(outcome.errors.length, `expected an out-of-band error: ${fixture.description}`).toBeGreaterThan(0); + break; + } + case 'ignored': { + expect(outcome.responses, `expected no response: ${fixture.description}`).toHaveLength(0); + expect(outcome.errors, `expected no out-of-band error: ${fixture.description}`).toHaveLength(0); + break; + } + } + }); +}); diff --git a/packages/core/test/packageTopologyPins.test.ts b/packages/core/test/packageTopologyPins.test.ts new file mode 100644 index 0000000000..9a12a303b6 --- /dev/null +++ b/packages/core/test/packageTopologyPins.test.ts @@ -0,0 +1,146 @@ +/** + * Behavior-surface pins: workspace package topology and export maps. + * + * The published surface of the SDK is the set of public packages and their + * export-map entries. Consumers resolve deep subpaths through these maps, so + * adding, removing, or renaming an entry — or flipping a private flag — is a + * consumer-visible change. This pins the manifest-level topology: every change + * to it must be deliberate (update the pin, add a changeset, and document the + * migration). Runtime resolvability of the built entries is covered by the + * integration test workspace. + * + * See docs/behavior-surface-pins.md for the maintenance protocol. + */ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, test } from 'vitest'; + +const packagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +interface PackageManifest { + name: string; + private?: boolean; + type?: string; + files?: string[]; + bin?: Record; + exports?: Record; +} + +function readManifest(relativeDir: string): PackageManifest { + return JSON.parse(readFileSync(join(packagesDir, relativeDir, 'package.json'), 'utf8')) as PackageManifest; +} + +/** dir (relative to packages/) → expected manifest shape */ +const PUBLIC_PACKAGES: Record }> = { + client: { + name: '@modelcontextprotocol/client', + exportKeys: ['.', './stdio', './validators/ajv', './validators/cf-worker', './_shims'] + }, + server: { + name: '@modelcontextprotocol/server', + exportKeys: ['.', './stdio', './validators/ajv', './validators/cf-worker', './_shims'] + }, + 'server-legacy': { + name: '@modelcontextprotocol/server-legacy', + exportKeys: ['.', './sse', './auth'] + }, + 'middleware/express': { name: '@modelcontextprotocol/express', exportKeys: ['.'] }, + 'middleware/fastify': { name: '@modelcontextprotocol/fastify', exportKeys: ['.'] }, + 'middleware/hono': { name: '@modelcontextprotocol/hono', exportKeys: ['.'] }, + 'middleware/node': { name: '@modelcontextprotocol/node', exportKeys: ['.'] }, + codemod: { + name: '@modelcontextprotocol/codemod', + exportKeys: ['.'], + bin: { 'mcp-codemod': './dist/cli.mjs' } + } +}; + +describe('public package topology', () => { + for (const [dir, expected] of Object.entries(PUBLIC_PACKAGES)) { + describe(expected.name, () => { + const manifest = readManifest(dir); + + test('is published under the pinned name', () => { + expect(manifest.name).toBe(expected.name); + expect(manifest.private).not.toBe(true); + }); + + test('export-map keys are pinned exactly', () => { + expect(Object.keys(manifest.exports ?? {})).toEqual(expected.exportKeys); + }); + + test('ships ESM only', () => { + expect(manifest.type).toBe('module'); + // No entry may grow a 'require' condition: the v2 packages are + // ESM-only by design (a CJS build would be a new public surface). + const conditionsOf = (entry: unknown): string[] => + entry !== null && typeof entry === 'object' + ? Object.entries(entry).flatMap(([key, value]) => [key, ...conditionsOf(value)]) + : []; + for (const entry of Object.values(manifest.exports ?? {})) { + expect(conditionsOf(entry)).not.toContain('require'); + } + }); + + test('publishes only dist', () => { + expect(manifest.files).toEqual(['dist']); + }); + + if (expected.bin) { + test('bin entries are pinned', () => { + expect(manifest.bin).toEqual(expected.bin); + }); + } else { + test('declares no bin entries', () => { + expect(manifest.bin).toBeUndefined(); + }); + } + }); + } +}); + +describe('the package set itself is pinned', () => { + /** Every directory under packages/ (one level, plus middleware/*) holding a package.json. */ + function discoverManifestDirs(): string[] { + const dirs: string[] = []; + for (const entry of readdirSync(packagesDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + if (existsSync(join(packagesDir, entry.name, 'package.json'))) { + dirs.push(entry.name); + continue; + } + for (const nested of readdirSync(join(packagesDir, entry.name), { withFileTypes: true })) { + if (nested.isDirectory() && existsSync(join(packagesDir, entry.name, nested.name, 'package.json'))) { + dirs.push(`${entry.name}/${nested.name}`); + } + } + } + return dirs.sort(); + } + + test('every manifest under packages/ is either a pinned public package or core', () => { + // The workspace glob (packages/**/*) auto-adopts any new directory and + // the changesets config publishes every non-private package, so the SET + // of packages is itself published surface. A new package must be added + // to PUBLIC_PACKAGES here deliberately (or pinned as private below) — + // otherwise it would ship to npm without any pin applying to it. + expect(discoverManifestDirs()).toEqual([...Object.keys(PUBLIC_PACKAGES), 'core'].sort()); + }); +}); + +describe('internal packages stay private', () => { + test('@modelcontextprotocol/core is private (bundled into client/server dists)', () => { + const manifest = readManifest('core'); + expect(manifest.name).toBe('@modelcontextprotocol/core'); + expect(manifest.private).toBe(true); + }); + + test('the workspace root is private', () => { + const manifest = JSON.parse(readFileSync(join(packagesDir, '..', 'package.json'), 'utf8')) as PackageManifest; + expect(manifest.private).toBe(true); + }); +}); diff --git a/packages/core/test/shared/classifyInboundMessage.test.ts b/packages/core/test/shared/classifyInboundMessage.test.ts new file mode 100644 index 0000000000..1b86f690df --- /dev/null +++ b/packages/core/test/shared/classifyInboundMessage.test.ts @@ -0,0 +1,107 @@ +/** + * Per-message era predicate for long-lived dual-era channels + * (`classifyInboundMessage`) — the body-primary rule (Q2) in its stdio form, + * with the T11 sharpening: classification keys on the SPECIFIC reserved + * envelope key (`io.modelcontextprotocol/protocolVersion`), never on bare + * `_meta` presence. + */ +import { describe, expect, it } from 'vitest'; + +import { classifyInboundMessage } from '../../src/shared/inboundClassification.js'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + LOG_LEVEL_META_KEY, + PROTOCOL_VERSION_META_KEY +} from '../../src/types/index.js'; + +const MODERN = '2026-07-28'; + +const fullEnvelope = (version: string) => ({ + [PROTOCOL_VERSION_META_KEY]: version, + [CLIENT_INFO_META_KEY]: { name: 'fixture-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}); + +describe('classifyInboundMessage (per-message body-primary predicate)', () => { + it('classifies `initialize` as legacy and carries the requested version as the revision', () => { + const classification = classifyInboundMessage({ + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '1' } } + }); + expect(classification).toEqual({ era: 'legacy', revision: '2025-06-18' }); + }); + + it('classifies `initialize` without a parsable protocolVersion as legacy with no revision', () => { + expect(classifyInboundMessage({ method: 'initialize', params: {} })).toEqual({ era: 'legacy' }); + expect(classifyInboundMessage({ method: 'initialize' })).toEqual({ era: 'legacy' }); + }); + + it('classifies `initialize` REQUESTING a modern revision as a bare legacy classification (initialize never negotiates a modern era)', () => { + const classification = classifyInboundMessage({ + method: 'initialize', + params: { protocolVersion: MODERN, capabilities: {}, clientInfo: { name: 'c', version: '1' } } + }); + expect(classification).toEqual({ era: 'legacy' }); + }); + + it('classifies a message carrying the reserved protocol-version envelope key as modern with the claimed revision', () => { + const classification = classifyInboundMessage({ + method: 'tools/list', + params: { _meta: fullEnvelope(MODERN) } + }); + expect(classification).toEqual({ era: 'modern', revision: MODERN }); + }); + + it('classifies an envelope claim naming a 2025-era revision as legacy with that revision', () => { + const classification = classifyInboundMessage({ + method: 'tools/list', + params: { _meta: { [PROTOCOL_VERSION_META_KEY]: '2025-06-18' } } + }); + expect(classification).toEqual({ era: 'legacy', revision: '2025-06-18' }); + }); + + it('classifies a claim with a non-string protocol-version value as a modern claim (validated at dispatch, never silently legacy)', () => { + const classification = classifyInboundMessage({ + method: 'tools/list', + params: { _meta: { [PROTOCOL_VERSION_META_KEY]: 42 } } + }); + expect(classification).toEqual({ era: 'modern' }); + }); + + it('T11: a legacy client carrying only `progressToken` in `_meta` classifies legacy — never bare `_meta` presence', () => { + const classification = classifyInboundMessage({ + method: 'tools/call', + params: { name: 'echo', arguments: {}, _meta: { progressToken: 7 } } + }); + expect(classification).toEqual({ era: 'legacy' }); + }); + + it('T11: other reserved envelope keys without the protocol-version key do NOT constitute a claim', () => { + const classification = classifyInboundMessage({ + method: 'tools/call', + params: { + name: 'echo', + arguments: {}, + _meta: { + [CLIENT_INFO_META_KEY]: { name: 'c', version: '1' }, + [CLIENT_CAPABILITIES_META_KEY]: {}, + [LOG_LEVEL_META_KEY]: 'info' + } + } + }); + expect(classification).toEqual({ era: 'legacy' }); + }); + + it('classifies a claim-less request as legacy', () => { + expect(classifyInboundMessage({ method: 'tools/list', params: {} })).toEqual({ era: 'legacy' }); + expect(classifyInboundMessage({ method: 'ping' })).toEqual({ era: 'legacy' }); + }); + + it('classifies notifications by the same body-primary rule', () => { + expect(classifyInboundMessage({ method: 'notifications/cancelled', params: { requestId: 1 } })).toEqual({ era: 'legacy' }); + expect( + classifyInboundMessage({ method: 'notifications/cancelled', params: { requestId: 1, _meta: fullEnvelope(MODERN) } }) + ).toEqual({ era: 'modern', revision: MODERN }); + }); +}); 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/customMethods.test.ts b/packages/core/test/shared/customMethods.test.ts index ffee5b9a7d..b50c376793 100644 --- a/packages/core/test/shared/customMethods.test.ts +++ b/packages/core/test/shared/customMethods.test.ts @@ -42,7 +42,15 @@ describe('Protocol custom-method support', () => { expect(result.items).toEqual(['result for hello']); }); - it('strips _meta from params before validation', async () => { + it('passes _meta to custom-handler validation, minus the reserved envelope keys (deliberate flip)', async () => { + // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): custom handlers + // used to have _meta DELETED before their params validation. They + // now receive it present-minus-reserved — the wire-only lift has + // already removed the io.modelcontextprotocol/* envelope keys — + // making the custom path consistent with the spec-method path. + // Strict consumer schemas that reject unknown keys must now model + // (or strip) _meta. Changeset: codec-split-wire-break; + // docs/migration.md "custom handlers receive _meta". const [a, b] = await pair(); const Strict = z.strictObject({ x: z.number() }); b.setRequestHandler('acme/strict', { params: Strict }, async params => { @@ -50,8 +58,20 @@ describe('Protocol custom-method support', () => { return {}; }); - const result = await a.request({ method: 'acme/strict', params: { x: 1, _meta: { progressToken: 't' } } }, z.object({})); - expect(result).toEqual({}); + // A strict schema now sees the metadata and rejects it… + await expect( + a.request({ method: 'acme/strict', params: { x: 1, _meta: { progressToken: 't' } } }, z.object({})) + ).rejects.toThrow(ProtocolError); + + // …while a schema that models _meta receives it verbatim. + const WithMeta = z.strictObject({ x: z.number(), _meta: z.record(z.string(), z.unknown()).optional() }); + let seenParams: unknown; + b.setRequestHandler('acme/withMeta', { params: WithMeta }, async params => { + seenParams = params; + return {}; + }); + await a.request({ method: 'acme/withMeta', params: { x: 2, _meta: { progressToken: 't' } } }, z.object({})); + expect(seenParams).toEqual({ x: 2, _meta: { progressToken: 't' } }); }); it('rejects invalid params with ProtocolError(InvalidParams)', async () => { @@ -112,17 +132,22 @@ describe('Protocol custom-method support', () => { expect(seen).toEqual([{ stage: 'fetch', pct: 0.5 }]); }); - it('passes the raw notification (with _meta) as the second handler argument', async () => { + it('passes _meta through custom-notification validation, minus reserved keys (deliberate flip)', async () => { + // Same behavior migration as the request path: _meta is no longer + // deleted before the consumer schema runs (ledgered; changeset: + // codec-split-wire-break). const [a, b] = await pair(); - const Strict = z.strictObject({ stage: z.string() }); + const WithMeta = z.strictObject({ stage: z.string(), _meta: z.record(z.string(), z.unknown()).optional() }); + let seenParams: unknown; let seenMeta: unknown; - b.setNotificationHandler('acme/searchProgress', { params: Strict }, (params, notification) => { - expect(params).toEqual({ stage: 'fetch' }); + b.setNotificationHandler('acme/searchProgress', { params: WithMeta }, (params, notification) => { + seenParams = params; seenMeta = notification.params?._meta; }); await a.notification({ method: 'acme/searchProgress', params: { stage: 'fetch', _meta: { traceId: 't1' } } }); await new Promise(r => setTimeout(r, 0)); + expect(seenParams).toEqual({ stage: 'fetch', _meta: { traceId: 't1' } }); expect(seenMeta).toEqual({ traceId: 't1' }); }); }); diff --git a/packages/core/test/shared/errorHttpStatusMatrix.test.ts b/packages/core/test/shared/errorHttpStatusMatrix.test.ts new file mode 100644 index 0000000000..7f505daece --- /dev/null +++ b/packages/core/test/shared/errorHttpStatusMatrix.test.ts @@ -0,0 +1,89 @@ +/** + * 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. + * + * The header/body mismatch family is pinned to `-32001` (HeaderMismatch) and + * the missing-envelope cells to `-32602`, the assignments asserted by the + * published conformance suite. + * + * 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 { HEADER_MISMATCH_ERROR_CODE, httpStatusForErrorCode, LADDER_ERROR_HTTP_STATUS } 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 — header/body mismatch family', () => { + test('the header/body mismatch family is pinned to -32001 (HeaderMismatch) and maps to HTTP 400', () => { + expect(HEADER_MISMATCH_ERROR_CODE).toBe(-32_001); + expect(LADDER_ERROR_HTTP_STATUS[HEADER_MISMATCH_ERROR_CODE]).toBe(400); + expect(httpStatusForErrorCode(HEADER_MISMATCH_ERROR_CODE, 'ladder')).toBe(400); + }); +}); diff --git a/packages/core/test/shared/inboundClassification.test.ts b/packages/core/test/shared/inboundClassification.test.ts new file mode 100644 index 0000000000..d288fd21d8 --- /dev/null +++ b/packages/core/test/shared/inboundClassification.test.ts @@ -0,0 +1,469 @@ +/** + * Unit tests for the inbound HTTP classifier (`classifyInboundRequest`) and + * the envelope claim helpers: the body-primary era predicate, claim + * detection, envelope validation with self-identifying issues, the header + * cross-checks, notification routing, element-wise batch classification, and + * the modern-only (strict) rejection mapping. + * + * The header/body mismatch cells are pinned to `-32001` (HeaderMismatch) and + * the missing-envelope / missing-protocol-version cells to `-32602` (invalid + * params naming the missing key(s)) — the assignments asserted by the + * published conformance suite. + */ +import { describe, expect, test } from 'vitest'; + +import { hasEnvelopeClaim, validateEnvelopeMeta } from '../../src/shared/envelope.js'; +import type { InboundHttpRequest, InboundLegacyRoute } from '../../src/shared/inboundClassification.js'; +import { classifyInboundRequest, modernOnlyStrictRejection } from '../../src/shared/inboundClassification.js'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../src/types/constants.js'; + +const MODERN_REVISION = '2026-07-28'; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'classifier-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +const modernToolsCall = (meta: Record = ENVELOPE) => ({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'echo', arguments: {}, _meta: meta } +}); + +const legacyToolsList = () => ({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); + +const initializeRequest = (protocolVersion = '2025-06-18') => ({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion, capabilities: {}, clientInfo: { name: 'legacy-client', version: '1.0.0' } } +}); + +const notification = (method = 'notifications/initialized', meta?: Record) => ({ + jsonrpc: '2.0', + method, + ...(meta === undefined ? {} : { params: { _meta: meta } }) +}); + +const post = (body: unknown, headers: { protocolVersion?: string; mcpMethod?: string } = {}): InboundHttpRequest => ({ + httpMethod: 'POST', + body, + ...(headers.protocolVersion !== undefined && { protocolVersionHeader: headers.protocolVersion }), + ...(headers.mcpMethod !== undefined && { mcpMethodHeader: headers.mcpMethod }) +}); + +const expectMismatch = (outcome: ReturnType, cell: string) => { + expect(outcome.kind).toBe('reject'); + if (outcome.kind !== 'reject') return; + expect(outcome.cell).toBe(cell); + expect(outcome.rung).toBe('era-classification'); + expect(outcome.httpStatus).toBe(400); + // Pinned: a header/body disagreement is a header-validation failure and + // answers -32001 (HeaderMismatch), per the published conformance suite. + expect(outcome.settled).toBe(true); + expect(outcome.code).toBe(-32_001); +}; + +describe('envelope claim detection (claim = the reserved protocol-version key)', () => { + test('a progress-token-only _meta is not a claim', () => { + expect(hasEnvelopeClaim({ _meta: { progressToken: 'token-1' } })).toBe(false); + }); + + test('client info / client capabilities alone are not a claim', () => { + expect( + hasEnvelopeClaim({ + _meta: { [CLIENT_INFO_META_KEY]: { name: 'c', version: '1' }, [CLIENT_CAPABILITIES_META_KEY]: {} } + }) + ).toBe(false); + }); + + test('stray reserved-prefix keys are ignored by claim detection', () => { + expect(hasEnvelopeClaim({ _meta: { 'io.modelcontextprotocol/somethingElse': true } })).toBe(false); + }); + + test('the protocol-version key alone is a claim, even with a non-string value', () => { + expect(hasEnvelopeClaim({ _meta: { [PROTOCOL_VERSION_META_KEY]: 42 } })).toBe(true); + }); +}); + +describe('envelope validation issues are self-identifying (key + problem)', () => { + test('missing required keys are reported in canonical order', () => { + const issues = validateEnvelopeMeta({ [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION }); + expect(issues.map(issue => issue.key)).toEqual([CLIENT_INFO_META_KEY, CLIENT_CAPABILITIES_META_KEY]); + expect(issues.every(issue => issue.problem === 'missing')).toBe(true); + }); + + test('a malformed value inside a present key names the key', () => { + const issues = validateEnvelopeMeta({ ...ENVELOPE, [CLIENT_INFO_META_KEY]: { version: '1.0.0' } }); + expect(issues.length).toBeGreaterThan(0); + expect(issues[0]?.key).toContain(CLIENT_INFO_META_KEY); + expect(issues[0]?.problem).not.toBe('missing'); + }); + + test('a complete, well-formed envelope produces no issues', () => { + expect(validateEnvelopeMeta(ENVELOPE)).toEqual([]); + }); +}); + +describe('body-primary era predicate', () => { + test('an envelope-claiming request with a matching header classifies modern', () => { + const outcome = classifyInboundRequest(post(modernToolsCall(), { protocolVersion: MODERN_REVISION })); + expect(outcome).toMatchObject({ + kind: 'modern', + messageKind: 'request', + classification: { era: 'modern', revision: MODERN_REVISION } + }); + }); + + test('a header-stripped request still classifies modern from the body claim alone', () => { + // Robustness to proxies/CDNs stripping the MCP-Protocol-Version header: + // the body claim is primary. + const outcome = classifyInboundRequest(post(modernToolsCall())); + expect(outcome).toMatchObject({ + kind: 'modern', + messageKind: 'request', + classification: { era: 'modern', revision: MODERN_REVISION } + }); + }); + + test('a claim-less request is legacy traffic and carries no classification', () => { + const outcome = classifyInboundRequest(post(legacyToolsList(), { protocolVersion: '2025-06-18' })); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'no-claim', requestedVersion: '2025-06-18' }); + expect('classification' in outcome).toBe(false); + }); + + test('initialize is the legacy handshake by definition', () => { + const outcome = classifyInboundRequest(post(initializeRequest('2025-03-26'))); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'initialize', requestedVersion: '2025-03-26' }); + }); + + test('an initialize carrying a valid modern envelope claim classifies modern (the claim wins over the handshake rule)', () => { + // Body-primary: no headers at all, the valid claim alone decides. The + // modern path then answers `initialize` as method-not-found, exactly + // like every other method the modern revision does not define. + const body = { jsonrpc: '2.0', id: 7, method: 'initialize', params: { _meta: ENVELOPE } }; + expect(classifyInboundRequest(post(body))).toMatchObject({ + kind: 'modern', + messageKind: 'request', + classification: { era: 'modern', revision: MODERN_REVISION } + }); + + // The same request with conformant standard headers (the wire shape a + // modern client actually sends) classifies the same way. + const withHeaders = classifyInboundRequest(post(body, { protocolVersion: MODERN_REVISION, mcpMethod: 'initialize' })); + expect(withHeaders).toMatchObject({ kind: 'modern', classification: { era: 'modern', revision: MODERN_REVISION } }); + }); + + test('an initialize with a malformed envelope claim keeps the legacy-handshake classification', () => { + const body = { + jsonrpc: '2.0', + id: 7, + method: 'initialize', + params: { protocolVersion: '2025-06-18', _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION } } + }; + expect(classifyInboundRequest(post(body))).toMatchObject({ kind: 'legacy', reason: 'initialize', requestedVersion: '2025-06-18' }); + }); + + test('an initialize whose valid envelope claim names a pre-2026 revision keeps the legacy-handshake classification', () => { + const meta = { ...ENVELOPE, [PROTOCOL_VERSION_META_KEY]: '2025-06-18' }; + const body = { jsonrpc: '2.0', id: 7, method: 'initialize', params: { _meta: meta } }; + expect(classifyInboundRequest(post(body))).toMatchObject({ kind: 'legacy', reason: 'initialize' }); + }); + + test('GET and DELETE are method-routed legacy session operations', () => { + expect(classifyInboundRequest({ httpMethod: 'GET' })).toMatchObject({ kind: 'legacy', reason: 'http-method' }); + expect(classifyInboundRequest({ httpMethod: 'DELETE' })).toMatchObject({ kind: 'legacy', reason: 'http-method' }); + }); + + test('a claim naming a legacy revision keeps the named revision on the classification', () => { + // The envelope mechanism naming a pre-2026 revision is carried as-is; + // the serving instance answers it through the protocol-version + // mismatch handoff rather than being silently re-routed. + const meta = { ...ENVELOPE, [PROTOCOL_VERSION_META_KEY]: '2025-06-18' }; + const outcome = classifyInboundRequest(post(modernToolsCall(meta))); + expect(outcome).toMatchObject({ kind: 'modern', classification: { era: 'legacy', revision: '2025-06-18' } }); + }); + + test('a claim with a malformed envelope is rejected, never silently treated as legacy', () => { + const meta = { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION }; + const outcome = classifyInboundRequest(post(modernToolsCall(meta))); + expect(outcome).toMatchObject({ + kind: 'reject', + rung: 'envelope', + cell: 'envelope-invalid', + httpStatus: 400, + code: -32_602, + settled: true, + data: { envelope: { key: CLIENT_INFO_META_KEY, problem: 'missing' } } + }); + }); + + test('a claim with malformed client capabilities names the offending key', () => { + const meta = { ...ENVELOPE, [CLIENT_CAPABILITIES_META_KEY]: { sampling: 'yes' } }; + const outcome = classifyInboundRequest(post(modernToolsCall(meta))); + expect(outcome.kind).toBe('reject'); + if (outcome.kind !== 'reject') return; + expect(outcome.code).toBe(-32_602); + const data = outcome.data as { envelope: { key: string } }; + expect(data.envelope.key).toContain(CLIENT_CAPABILITIES_META_KEY); + }); +}); + +describe('header cross-checks (-32001 HeaderMismatch) and the missing-envelope rejection (-32602)', () => { + test('a body claim disagreeing with the protocol-version header is a mismatch outcome', () => { + const outcome = classifyInboundRequest(post(modernToolsCall(), { protocolVersion: '2025-06-18' })); + expectMismatch(outcome, 'header-body-version-mismatch'); + }); + + test('a modern header on a claim-less body is rejected with invalid params naming the missing _meta envelope', () => { + // Never an upgrade and never a silent legacy fallthrough: the modern + // revisions require the per-request envelope, so the request is + // answered as missing required params. + const outcome = classifyInboundRequest(post(legacyToolsList(), { protocolVersion: MODERN_REVISION })); + expect(outcome).toMatchObject({ + kind: 'reject', + rung: 'envelope', + cell: 'modern-header-without-claim', + httpStatus: 400, + code: -32_602, + settled: true, + data: { envelope: { missing: ['_meta'] } } + }); + }); + + test('a modern header on a body whose _meta lacks the protocol-version key names that key as missing', () => { + const body = { + jsonrpc: '2.0', + id: 4, + method: 'tools/list', + params: { _meta: { [CLIENT_INFO_META_KEY]: { name: 'c', version: '1' }, [CLIENT_CAPABILITIES_META_KEY]: {} } } + }; + const outcome = classifyInboundRequest(post(body, { protocolVersion: MODERN_REVISION })); + expect(outcome).toMatchObject({ + kind: 'reject', + rung: 'envelope', + cell: 'modern-header-without-claim', + httpStatus: 400, + code: -32_602, + settled: true, + data: { envelope: { missing: [PROTOCOL_VERSION_META_KEY] } } + }); + if (outcome.kind !== 'reject') return; + expect(outcome.message).toContain(PROTOCOL_VERSION_META_KEY); + }); + + test('initialize with a modern protocol-version header is a mismatch outcome', () => { + const outcome = classifyInboundRequest(post(initializeRequest(), { protocolVersion: MODERN_REVISION })); + expectMismatch(outcome, 'initialize-with-modern-header'); + }); + + test('an enveloped initialize whose claim disagrees with the protocol-version header is still a mismatch outcome', () => { + // The claim precedence never bypasses the cross-checks: an initialize + // carrying a valid modern claim is checked against the header exactly + // like any other enveloped request. + const body = { jsonrpc: '2.0', id: 7, method: 'initialize', params: { _meta: ENVELOPE } }; + const outcome = classifyInboundRequest(post(body, { protocolVersion: '2025-06-18' })); + expectMismatch(outcome, 'header-body-version-mismatch'); + }); + + test('an Mcp-Method header disagreeing with the body method is a mismatch outcome on modern requests', () => { + const outcome = classifyInboundRequest(post(modernToolsCall(), { protocolVersion: MODERN_REVISION, mcpMethod: 'tools/list' })); + expectMismatch(outcome, 'method-header-mismatch'); + }); + + test('a matching Mcp-Method header passes', () => { + const outcome = classifyInboundRequest(post(modernToolsCall(), { protocolVersion: MODERN_REVISION, mcpMethod: 'tools/call' })); + expect(outcome.kind).toBe('modern'); + }); + + test('the Mcp-Method header is never enforced on legacy requests', () => { + const outcome = classifyInboundRequest(post(legacyToolsList(), { protocolVersion: '2025-06-18', mcpMethod: 'tools/call' })); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'no-claim' }); + }); +}); + +describe('notification routing (header determinative when the body carries no claim)', () => { + test('a modern protocol-version header routes a claim-less notification to modern serving', () => { + const outcome = classifyInboundRequest(post(notification(), { protocolVersion: MODERN_REVISION })); + expect(outcome).toMatchObject({ + kind: 'modern', + messageKind: 'notification', + classification: { era: 'modern', revision: MODERN_REVISION } + }); + }); + + test('a header-stripped notification stays legacy traffic', () => { + const outcome = classifyInboundRequest(post(notification())); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'notification' }); + }); + + test('a legacy protocol-version header keeps the notification legacy', () => { + const outcome = classifyInboundRequest(post(notification(), { protocolVersion: '2025-06-18' })); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'notification', requestedVersion: '2025-06-18' }); + }); + + test('the Mcp-Method header is validated on modern notifications', () => { + const outcome = classifyInboundRequest( + post(notification('notifications/progress'), { protocolVersion: MODERN_REVISION, mcpMethod: 'notifications/cancelled' }) + ); + expectMismatch(outcome, 'notification-method-header-mismatch'); + }); + + test('the Mcp-Method header is never enforced on legacy notifications', () => { + const outcome = classifyInboundRequest( + post(notification('notifications/progress'), { protocolVersion: '2025-06-18', mcpMethod: 'notifications/cancelled' }) + ); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'notification' }); + }); + + test('a notification body claim wins over the header and a disagreement is rejected', () => { + const meta = { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION }; + const claimed = classifyInboundRequest(post(notification('notifications/progress', meta))); + expect(claimed).toMatchObject({ kind: 'modern', classification: { revision: MODERN_REVISION } }); + + const conflicting = classifyInboundRequest(post(notification('notifications/progress', meta), { protocolVersion: '2025-06-18' })); + expectMismatch(conflicting, 'notification-header-body-version-mismatch'); + }); + + test('a notification claim with a malformed value is rejected, naming the offending key', () => { + // Validated exactly like a request claim: invalid params naming the + // key — never silently losing to (or overriding) a disagreeing header. + const meta = { [PROTOCOL_VERSION_META_KEY]: 42 }; + const outcome = classifyInboundRequest(post(notification('notifications/progress', meta))); + expect(outcome).toMatchObject({ + kind: 'reject', + rung: 'envelope', + cell: 'notification-envelope-invalid', + httpStatus: 400, + code: -32_602, + settled: true + }); + if (outcome.kind !== 'reject') return; + const data = outcome.data as { envelope: { key: string } }; + expect(data.envelope.key).toBe(PROTOCOL_VERSION_META_KEY); + }); + + test('a notification claim with a malformed value is rejected the same way when a legacy header disagrees', () => { + const meta = { [PROTOCOL_VERSION_META_KEY]: 42 }; + const outcome = classifyInboundRequest(post(notification('notifications/progress', meta), { protocolVersion: '2025-06-18' })); + expect(outcome).toMatchObject({ kind: 'reject', rung: 'envelope', cell: 'notification-envelope-invalid', code: -32_602 }); + }); + + test('a notification with no claim at all keeps header-determinative routing (not envelope-validated)', () => { + // Only a present claim is validated; claim-less notifications keep the + // header-determinative routing above unchanged. + expect(classifyInboundRequest(post(notification(), { protocolVersion: MODERN_REVISION }))).toMatchObject({ kind: 'modern' }); + expect(classifyInboundRequest(post(notification()))).toMatchObject({ kind: 'legacy', reason: 'notification' }); + }); +}); + +describe('element-wise batch classification', () => { + test('an all-legacy array stays legacy traffic unchanged', () => { + const outcome = classifyInboundRequest(post([legacyToolsList(), notification()])); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'batch' }); + }); + + test('a single-element array is still an array', () => { + const outcome = classifyInboundRequest(post([legacyToolsList()])); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'batch' }); + }); + + test('an array containing a response element stays legacy traffic', () => { + const outcome = classifyInboundRequest(post([{ jsonrpc: '2.0', id: 9, result: {} }, legacyToolsList()])); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'batch' }); + }); + + test('an array containing a modern-claiming element is rejected', () => { + const outcome = classifyInboundRequest(post([legacyToolsList(), modernToolsCall()])); + expect(outcome).toMatchObject({ kind: 'reject', cell: 'batch-with-modern-element', code: -32_600, httpStatus: 400, settled: true }); + }); + + test('an array containing an invalid element is rejected', () => { + const outcome = classifyInboundRequest(post([legacyToolsList(), { not: 'json-rpc' }])); + expect(outcome).toMatchObject({ kind: 'reject', cell: 'batch-with-invalid-element', code: -32_600, httpStatus: 400 }); + }); + + test('an empty array is rejected', () => { + const outcome = classifyInboundRequest(post([])); + expect(outcome).toMatchObject({ kind: 'reject', cell: 'empty-batch', code: -32_600 }); + }); +}); + +describe('responses and malformed bodies', () => { + test('a posted result response is legacy session traffic', () => { + const outcome = classifyInboundRequest(post({ jsonrpc: '2.0', id: 3, result: {} })); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'response' }); + }); + + test('a posted error response is legacy session traffic', () => { + const outcome = classifyInboundRequest(post({ jsonrpc: '2.0', id: 3, error: { code: -32_000, message: 'oops' } })); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'response' }); + }); + + test('a body that is not a JSON-RPC message is rejected', () => { + const outcome = classifyInboundRequest(post({ hello: 'world' })); + expect(outcome).toMatchObject({ kind: 'reject', cell: 'invalid-json-rpc-body', code: -32_600, httpStatus: 400 }); + }); + + test('a missing body is rejected', () => { + const outcome = classifyInboundRequest({ httpMethod: 'POST' }); + expect(outcome).toMatchObject({ kind: 'reject', cell: 'invalid-json-rpc-body', code: -32_600 }); + }); +}); + +describe('modern-only (strict) rejection mapping', () => { + const SUPPORTED = [MODERN_REVISION]; + const legacyRoute = (body: unknown, headers: { protocolVersion?: string } = {}): InboundLegacyRoute => { + const outcome = classifyInboundRequest(post(body, headers)); + expect(outcome.kind).toBe('legacy'); + return outcome as InboundLegacyRoute; + }; + + test('an envelope-less request that named no version omits `requested` rather than fabricating one', () => { + const rejectionOutcome = modernOnlyStrictRejection(legacyRoute(legacyToolsList()), SUPPORTED); + expect(rejectionOutcome).toMatchObject({ + cell: 'modern-only-missing-envelope', + httpStatus: 400, + code: -32_004, + settled: true, + data: { supported: SUPPORTED } + }); + expect((rejectionOutcome?.data as { requested?: unknown })?.requested).toBeUndefined(); + expect(Object.keys(rejectionOutcome?.data as Record)).not.toContain('requested'); + expect(rejectionOutcome?.message).toContain('Unsupported protocol version'); + }); + + test('an envelope-less initialize names the version it requested', () => { + const rejectionOutcome = modernOnlyStrictRejection(legacyRoute(initializeRequest('2025-06-18')), SUPPORTED); + expect(rejectionOutcome).toMatchObject({ code: -32_004, data: { supported: SUPPORTED, requested: '2025-06-18' } }); + }); + + test('an envelope-less request echoes the protocol-version header it sent', () => { + const rejectionOutcome = modernOnlyStrictRejection(legacyRoute(legacyToolsList(), { protocolVersion: '2025-03-26' }), SUPPORTED); + expect(rejectionOutcome).toMatchObject({ code: -32_004, data: { requested: '2025-03-26' } }); + }); + + test('batch and response POSTs are invalid requests on a modern-only endpoint', () => { + expect(modernOnlyStrictRejection(legacyRoute([legacyToolsList()]), SUPPORTED)).toMatchObject({ code: -32_600, httpStatus: 400 }); + expect(modernOnlyStrictRejection(legacyRoute({ jsonrpc: '2.0', id: 1, result: {} }), SUPPORTED)).toMatchObject({ + code: -32_600, + httpStatus: 400 + }); + }); + + test('non-POST methods are not allowed on a modern-only endpoint', () => { + const route = classifyInboundRequest({ httpMethod: 'GET' }) as InboundLegacyRoute; + expect(modernOnlyStrictRejection(route, SUPPORTED)).toMatchObject({ + httpStatus: 405, + code: -32_000, + message: 'Method not allowed.' + }); + }); + + test('legacy-classified notifications are accepted-and-dropped (no rejection body)', () => { + const route = classifyInboundRequest(post(notification())) as InboundLegacyRoute; + expect(modernOnlyStrictRejection(route, SUPPORTED)).toBeUndefined(); + }); +}); diff --git a/packages/core/test/shared/inboundLadderCellSheet.test.ts b/packages/core/test/shared/inboundLadderCellSheet.test.ts new file mode 100644 index 0000000000..6713e3bd4b --- /dev/null +++ b/packages/core/test/shared/inboundLadderCellSheet.test.ts @@ -0,0 +1,433 @@ +/** + * The inbound validation-ladder cell sheet. + * + * Each row names one ladder cell, the conformance scenarios that exercise it + * (where one exists), and the expected outcome with its exact code and HTTP + * status. The header/body mismatch and missing-envelope cells were originally + * parameterized (asserted as candidate-set membership) while their error codes + * were under discussion upstream; they are now pinned to the assignments the + * published conformance suite asserts (`-32001` HeaderMismatch for header/body + * disagreements, `-32602` invalid params naming the missing key(s) for a + * missing envelope or missing protocol-version key). If a future published + * conformance release changes an assignment, the affected rows are re-derived + * here. + * + * Cells evaluated at protocol dispatch (the era registry gate, per-method + * params, capability assertion) are listed for ordering and status mapping + * only; their end-to-end HTTP assertions live with the per-request server + * transport tests in the server package. + */ +import { describe, expect, test } from 'vitest'; + +import type { InboundHttpRequest, InboundLadderRejection } from '../../src/shared/inboundClassification.js'; +import { + classifyInboundRequest, + httpStatusForErrorCode, + INBOUND_VALIDATION_LADDER, + LADDER_ERROR_HTTP_STATUS, + modernOnlyStrictRejection +} from '../../src/shared/inboundClassification.js'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../src/types/constants.js'; + +const MODERN_REVISION = '2026-07-28'; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'cell-sheet-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +const enveloped = (method: string, params: Record = {}) => ({ + jsonrpc: '2.0', + id: 1, + method, + params: { ...params, _meta: ENVELOPE } +}); +const bare = (method: string, params: Record = {}) => ({ jsonrpc: '2.0', id: 1, method, params }); +const post = (body: unknown, headers: { protocolVersion?: string; mcpMethod?: string } = {}): InboundHttpRequest => ({ + httpMethod: 'POST', + body, + ...(headers.protocolVersion !== undefined && { protocolVersionHeader: headers.protocolVersion }), + ...(headers.mcpMethod !== undefined && { mcpMethodHeader: headers.mcpMethod }) +}); + +interface SheetRow { + /** Stable cell identifier (matches `InboundLadderRejection.cell` for rejection cells). */ + cell: string; + /** Conformance scenarios exercising the cell, where one exists in the published referee. */ + conformance: readonly string[]; + /** The classifier input. */ + input: InboundHttpRequest; + /** Strict (modern-only) mapping applies: the legacy route is mapped through `modernOnlyStrictRejection`. */ + strict?: boolean; + /** The expected outcome for routing cells. */ + route?: 'legacy' | 'modern'; + /** The expected rejection, asserted exactly. */ + reject?: Partial; + /** Why the cell behaves the way it does. */ + rationale: string; +} + +const SHEET: readonly SheetRow[] = [ + /* --- Routing cells (pinned) --------------------------------------------------- */ + { + cell: 'modern-enveloped-request', + conformance: ['server-stateless'], + input: post(enveloped('tools/call', { name: 'echo', arguments: {} }), { protocolVersion: MODERN_REVISION }), + route: 'modern', + rationale: 'A request carrying the per-request envelope claim is modern-era traffic.' + }, + { + cell: 'modern-enveloped-request-header-stripped', + conformance: ['server-stateless'], + input: post(enveloped('tools/call', { name: 'echo', arguments: {} })), + route: 'modern', + rationale: 'Body-primary classification: a proxy stripping the protocol-version header must not change the era.' + }, + { + cell: 'legacy-claimless-request', + conformance: [], + input: post(bare('tools/list'), { protocolVersion: '2025-06-18' }), + route: 'legacy', + rationale: 'A request without an envelope claim is legacy traffic and is never classified.' + }, + { + cell: 'legacy-initialize', + conformance: [], + input: post(bare('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '1' } })), + route: 'legacy', + rationale: 'initialize is the legacy handshake by definition; the modern era has no initialize.' + }, + { + cell: 'modern-enveloped-initialize', + conformance: ['server-stateless'], + input: post(enveloped('initialize'), { protocolVersion: MODERN_REVISION, mcpMethod: 'initialize' }), + route: 'modern', + rationale: + 'A valid modern envelope claim wins over the initialize ⇒ legacy-handshake rule: the request is served on the modern path, ' + + 'where the modern registry answers initialize as method-not-found (-32601, HTTP 404 via the ladder status table) like every ' + + 'other method the revision does not define.' + }, + { + cell: 'legacy-method-routed-get', + conformance: [], + input: { httpMethod: 'GET' }, + route: 'legacy', + rationale: 'GET/DELETE are body-less 2025-era session operations; the modern era is POST-only.' + }, + { + cell: 'legacy-notification-stripped-header', + conformance: [], + input: post({ jsonrpc: '2.0', method: 'notifications/initialized' }), + route: 'legacy', + rationale: + 'A notification without a body claim or a modern header stays legacy traffic (dual mode routes it; strict mode accepts and drops it).' + }, + { + cell: 'modern-notification-by-header', + conformance: ['http-header-validation'], + input: post({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 1 } }, { protocolVersion: MODERN_REVISION }), + route: 'modern', + rationale: 'Notifications carry no body claim, so the modern protocol-version header is determinative for them.' + }, + { + cell: 'legacy-batch', + conformance: [], + input: post([bare('tools/list')]), + route: 'legacy', + rationale: 'All-legacy arrays go to legacy serving unchanged; a single-element array is still an array.' + }, + { + cell: 'legacy-response-post', + conformance: [], + input: post({ jsonrpc: '2.0', id: 5, result: {} }), + route: 'legacy', + rationale: 'Posted responses are 2025-era session traffic (replies to server-initiated requests).' + }, + + /* --- Edge rejection cells (pinned) -------------------------------------------- */ + { + cell: 'envelope-invalid', + conformance: ['server-stateless'], + input: post({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION } } }), + reject: { rung: 'envelope', httpStatus: 400, code: -32_602, settled: true }, + rationale: 'A present claim with a malformed envelope is invalid params naming the key — never a silent legacy fallthrough.' + }, + { + cell: 'batch-with-modern-element', + conformance: [], + input: post([bare('tools/list'), enveloped('tools/call', { name: 'echo', arguments: {} })]), + reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, + rationale: 'Element-wise batch rule: one modern element makes the array unservable on either path.' + }, + { + cell: 'batch-with-invalid-element', + conformance: [], + input: post([bare('tools/list'), { nonsense: true }]), + reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, + rationale: 'Element-wise batch rule: invalid elements are rejected rather than partially served.' + }, + { + cell: 'invalid-json-rpc-body', + conformance: [], + input: post({ hello: 'world' }), + reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, + rationale: + 'A POST body that is not a JSON-RPC message is an invalid request (-32600, the JSON-RPC-correct code). Deliberate ' + + 'divergence from the deployed 2025-era transport, which answers -32700 for the same parsed body; enumerated and ' + + 'exercised on both legs in the era-parity suite (server package).' + }, + { + cell: 'empty-batch', + conformance: [], + input: post([]), + reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, + rationale: + 'An empty JSON-RPC batch is an invalid request at the modern edge. Deliberate divergence from the deployed 2025-era ' + + 'transport, which accepts an empty array as containing only notifications (202, no body); enumerated and exercised on ' + + 'both legs in the era-parity suite (server package).' + }, + { + cell: 'notification-envelope-invalid', + conformance: [], + input: post({ jsonrpc: '2.0', method: 'notifications/progress', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: 42 } } }), + reject: { rung: 'envelope', httpStatus: 400, code: -32_602, settled: true }, + rationale: + 'A notification claim with a malformed protocol-version value is invalid params naming the key — exactly like the ' + + 'request path, never a silent win against (or loss to) a disagreeing header.' + }, + + /* --- Modern-only (strict) cells (pinned) --------------------------------------- */ + { + cell: 'modern-only-missing-envelope', + conformance: ['server-stateless'], + input: post(bare('tools/list')), + strict: true, + reject: { rung: 'era-classification', httpStatus: 400, code: -32_004, settled: true }, + rationale: + 'A modern-only endpoint answers envelope-less requests with the unsupported-protocol-version error and its supported list. ' + + 'This cell shares its numeric code with the disputed mismatch family but is itself settled.' + }, + { + cell: 'modern-only-missing-envelope-initialize', + conformance: ['server-stateless'], + input: post(bare('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '1' } })), + strict: true, + reject: { + rung: 'era-classification', + httpStatus: 400, + code: -32_004, + settled: true, + data: { supported: [MODERN_REVISION], requested: '2025-06-18' } + }, + rationale: + 'An envelope-less initialize on a modern-only endpoint is answered with the version error naming both sides — the ' + + 'unsupported-protocol-version rejection with the supported list stays reserved for envelope-less requests.' + }, + { + cell: 'modern-only-method-not-allowed', + conformance: [], + input: { httpMethod: 'DELETE' }, + strict: true, + reject: { rung: 'http-method', httpStatus: 405, code: -32_000, settled: true }, + rationale: 'Without legacy serving configured there is nothing to route GET/DELETE to.' + }, + { + cell: 'modern-only-batch-not-supported', + conformance: [], + input: post([bare('tools/list')]), + strict: true, + reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, + rationale: 'Batches are not part of the modern wire shape.' + }, + { + cell: 'modern-only-response-post', + conformance: [], + input: post({ jsonrpc: '2.0', id: 5, result: {} }), + strict: true, + reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, + rationale: 'There is no server-to-client request channel on the modern era, so posted responses are invalid requests.' + }, + + /* --- Header cross-check and missing-envelope cells (pinned to the published suite) --- */ + { + cell: 'header-body-version-mismatch', + conformance: ['server-stateless', 'http-header-validation', 'http-custom-header-server-validation'], + input: post(enveloped('tools/call', { name: 'echo', arguments: {} }), { protocolVersion: '2025-06-18' }), + reject: { rung: 'era-classification', httpStatus: 400, code: -32_001, settled: true }, + rationale: + 'Header/body protocol-version disagreement is a header-validation failure: -32001 (HeaderMismatch) on HTTP 400, as ' + + 'asserted by the published conformance suite.' + }, + { + cell: 'modern-header-without-claim', + conformance: ['server-stateless'], + input: post(bare('tools/list'), { protocolVersion: MODERN_REVISION }), + reject: { rung: 'envelope', httpStatus: 400, code: -32_602, settled: true }, + rationale: + 'A modern protocol-version header on a claim-less body is a modern-classified request missing its required _meta ' + + 'envelope: invalid params naming the missing key(s), never an upgrade and never a silent legacy fallthrough.' + }, + { + cell: 'initialize-with-modern-header', + conformance: [], + input: post(bare('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '1' } }), { + protocolVersion: MODERN_REVISION + }), + reject: { rung: 'era-classification', httpStatus: 400, code: -32_001, settled: true }, + rationale: + 'An envelope-less initialize classifies legacy; a modern header on it is a header/body disagreement and answers the ' + + 'same -32001 (HeaderMismatch) as the rest of the mismatch family.' + }, + { + cell: 'method-header-mismatch', + conformance: ['http-header-validation', 'http-custom-header-server-validation'], + input: post(enveloped('tools/call', { name: 'echo', arguments: {} }), { + protocolVersion: MODERN_REVISION, + mcpMethod: 'tools/list' + }), + reject: { rung: 'era-classification', httpStatus: 400, code: -32_001, settled: true }, + rationale: + 'The Mcp-Method header must describe the body it accompanies; a disagreement is a header-validation failure and ' + + 'answers -32001 (HeaderMismatch) on HTTP 400.' + }, + { + cell: 'notification-header-body-version-mismatch', + conformance: [], + input: post( + { jsonrpc: '2.0', method: 'notifications/progress', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION } } }, + { protocolVersion: '2025-06-18' } + ), + reject: { rung: 'era-classification', httpStatus: 400, code: -32_001, settled: true }, + rationale: + 'A notification body claim disagreeing with the protocol-version header is the same header-validation failure as the ' + + 'request cells above and answers the same -32001 (HeaderMismatch).' + }, + { + cell: 'notification-method-header-mismatch', + conformance: [], + input: post( + { jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: 1, progress: 1 } }, + { protocolVersion: MODERN_REVISION, mcpMethod: 'notifications/cancelled' } + ), + reject: { rung: 'era-classification', httpStatus: 400, code: -32_001, settled: true }, + rationale: + 'The Mcp-Method header must describe the notification body it accompanies (validated only when the notification ' + + 'classifies modern); a disagreement answers -32001 (HeaderMismatch).' + }, + { + cell: 'multi-fault-mismatched-claim-and-malformed-envelope', + conformance: ['server-stateless', 'http-header-validation'], + // The claim names a different version than the header AND the envelope + // is missing required keys: the envelope rung answers (the header + // cross-check is only evaluated on a valid envelope), so the emitted + // code is the envelope rung's -32602. + input: post( + { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION } } }, + { + protocolVersion: '2025-06-18' + } + ), + reject: { rung: 'envelope', httpStatus: 400, code: -32_602, settled: true }, + rationale: + 'Multi-fault precedence: envelope validity is checked before the header cross-check, so the malformed envelope answers ' + + 'with invalid params; the mismatch is never reached.' + } +]; + +describe('inbound validation-ladder cell sheet', () => { + const SUPPORTED = [MODERN_REVISION]; + + test.each(SHEET)('$cell', row => { + let outcome = classifyInboundRequest(row.input); + if (row.strict) { + expect(outcome.kind).toBe('legacy'); + if (outcome.kind !== 'legacy') return; + const mapped = modernOnlyStrictRejection(outcome, SUPPORTED); + expect(mapped).toBeDefined(); + outcome = mapped!; + } + + if (row.route !== undefined) { + expect(outcome.kind).toBe(row.route); + if (row.route === 'legacy') { + // Legacy routes never carry a classification (hand-wired and + // legacy traffic is never classified). + expect('classification' in outcome).toBe(false); + } + return; + } + + expect(outcome.kind).toBe('reject'); + if (outcome.kind !== 'reject') return; + + expect(outcome).toMatchObject(row.reject ?? {}); + }); + + test('every cell id is unique and every rejection row pins an expected outcome', () => { + const ids = SHEET.map(row => row.cell); + expect(new Set(ids).size).toBe(ids.length); + for (const row of SHEET.filter(candidate => candidate.route === undefined)) { + expect(row.reject?.code).toBeDefined(); + expect(row.reject?.httpStatus).toBeDefined(); + } + }); +}); + +describe('the validation ladder as data', () => { + test('rungs are uniquely named and strictly ordered', () => { + const orders = INBOUND_VALIDATION_LADDER.map(rung => rung.order); + expect(orders.toSorted((a, b) => a - b)).toEqual(orders); + expect(new Set(orders).size).toBe(orders.length); + expect(new Set(INBOUND_VALIDATION_LADDER.map(rung => rung.rung)).size).toBe(INBOUND_VALIDATION_LADDER.length); + }); + + test('the edge rungs precede the dispatch rungs', () => { + const lastEdge = Math.max(...INBOUND_VALIDATION_LADDER.filter(rung => rung.evaluatedAt === 'edge').map(rung => rung.order)); + const firstDispatch = Math.min( + ...INBOUND_VALIDATION_LADDER.filter(rung => rung.evaluatedAt === 'dispatch').map(rung => rung.order) + ); + expect(lastEdge).toBeLessThan(firstDispatch); + }); + + test('method existence outranks parameter validity in the rung order', () => { + const methodRegistry = INBOUND_VALIDATION_LADDER.find(rung => rung.rung === 'method-registry'); + const requestParams = INBOUND_VALIDATION_LADDER.find(rung => rung.rung === 'request-params'); + expect(methodRegistry!.order).toBeLessThan(requestParams!.order); + }); +}); + +describe('HTTP status mapping for ladder-originated errors (origin-keyed)', () => { + test('the table maps exactly the ladder-originated codes', () => { + // 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 + }); + }); + + test('the table never maps invalid params: the classifier envelope short-circuit is the only -32602 -> 400 source', () => { + expect(Object.keys(LADDER_ERROR_HTTP_STATUS)).not.toContain(String(-32_602)); + expect(httpStatusForErrorCode(-32_602, 'in-band')).toBe(200); + }); + + test('handler-originated errors stay in-band on HTTP 200, whatever their code', () => { + for (const code of [-32_603, -32_602, -32_601, -32_004, -32_002, -32_000, 1234]) { + expect(httpStatusForErrorCode(code, 'in-band')).toBe(200); + } + }); + + test('ladder-originated codes map to their HTTP statuses', () => { + expect(httpStatusForErrorCode(-32_601, 'ladder')).toBe(404); + expect(httpStatusForErrorCode(-32_004, 'ladder')).toBe(400); + expect(httpStatusForErrorCode(-32_003, 'ladder')).toBe(400); + expect(httpStatusForErrorCode(-32_001, 'ladder')).toBe(400); + }); +}); diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index 6e77430d61..309cf6a50a 100644 --- a/packages/core/test/shared/protocol.test.ts +++ b/packages/core/test/shared/protocol.test.ts @@ -22,6 +22,8 @@ import type { } from '../../src/types/index.js'; import { ProtocolError, ProtocolErrorCode } from '../../src/types/index.js'; import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; +import { rev2025Codec } from '../../src/wire/rev2025-11-25/codec.js'; // Test Protocol subclass for testing class TestProtocolImpl extends Protocol { @@ -910,3 +912,169 @@ describe('mergeCapabilities', () => { expect(merged).toEqual({}); }); }); + +describe('codec-seam hardening in the protocol funnels', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const flush = () => new Promise(resolve => setTimeout(resolve, 10)); + + test('a throw inside codec.encodeResult answers −32603 on the wire — the peer is never stranded', async () => { + const [peerTx, protocolTx] = InMemoryTransport.createLinkedPair(); + const sent: JSONRPCMessage[] = []; + peerTx.onmessage = message => void sent.push(message); + await peerTx.start(); + + const protocol = createTestProtocol(); + const errors: Error[] = []; + protocol.onerror = error => void errors.push(error); + protocol.setRequestHandler('acme/op', { params: z.looseObject({}) }, () => ({ ok: true }) as Result); + await protocol.connect(protocolTx); + + // The encode hop is the only throw-capable step between handler + // success and the transport send (and it grows stamping content in + // M3.2). Force it to throw once. + vi.spyOn(rev2025Codec, 'encodeResult').mockImplementationOnce(() => { + throw new Error('stamp exploded'); + }); + + await peerTx.send({ jsonrpc: '2.0', id: 1, method: 'acme/op', params: {} }); + await flush(); + + expect(sent).toHaveLength(1); + expect((sent[0] as JSONRPCErrorResponse).error).toMatchObject({ code: ProtocolErrorCode.InternalError }); + // Surfaced locally too. + expect(errors.some(error => error.message.includes('Failed to encode result'))).toBe(true); + + // The connection stays serviceable: the next request round-trips. + await peerTx.send({ jsonrpc: '2.0', id: 2, method: 'acme/op', params: {} }); + await flush(); + expect(sent).toHaveLength(2); + expect((sent[1] as JSONRPCResultResponse).result).toMatchObject({ ok: true }); + + await protocol.close(); + }); + + test('a synchronous throw out of codec.decodeResult rejects the request instead of escaping into transport.onmessage', async () => { + const [protocolTx, peerTx] = InMemoryTransport.createLinkedPair(); + peerTx.onmessage = message => { + const request = message as JSONRPCRequest; + void peerTx.send({ jsonrpc: '2.0', id: request.id, result: {} }); + }; + await peerTx.start(); + + const protocol = createTestProtocol(); + await protocol.connect(protocolTx); + + // The response callback runs synchronously inside _onresponse; an + // unguarded throw here would propagate into the transport instead of + // failing the request. (The concrete production vector is the 2026 + // codec's method-keyed schema lookup — see the own-key guard in + // rev2026-07-28/codec.ts.) + vi.spyOn(rev2025Codec, 'decodeResult').mockImplementationOnce(() => { + throw new Error('decode exploded'); + }); + + await expect(protocol.request({ method: 'ping' })).rejects.toThrow('decode exploded'); + + await protocol.close(); + }); +}); + +describe('inbound validation precedence: −32601 outranks envelope −32602', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const flush = () => new Promise(resolve => setTimeout(resolve, 10)); + + async function wireWithFailingEnvelope(setup?: (protocol: TestProtocolImpl) => void) { + const [peerTx, protocolTx] = InMemoryTransport.createLinkedPair(); + const sent: JSONRPCMessage[] = []; + peerTx.onmessage = message => void sent.push(message); + await peerTx.start(); + + const protocol = createTestProtocol(); + setup?.(protocol); + await protocol.connect(protocolTx); + + // Force the era's envelope check to fail for every request, so the + // test pins WHERE in the ladder it runs, independent of era wiring. + vi.spyOn(rev2025Codec, 'checkInboundEnvelope').mockImplementation(() => 'Request is missing the required _meta envelope'); + + return { peerTx, sent, flush }; + } + + test('a genuinely unknown method answers −32601 even when the envelope check would also fail', async () => { + const { peerTx, sent } = await wireWithFailingEnvelope(); + + await peerTx.send({ jsonrpc: '2.0', id: 1, method: 'acme/no-such-method', params: {} }); + await flush(); + + expect(sent).toHaveLength(1); + expect((sent[0] as JSONRPCErrorResponse).error).toMatchObject({ + code: ProtocolErrorCode.MethodNotFound, + message: 'Method not found' + }); + }); + + test('a served method still answers −32602 when the envelope check fails', async () => { + const { peerTx, sent } = await wireWithFailingEnvelope(protocol => { + protocol.setRequestHandler('acme/known', { params: z.looseObject({}) }, () => ({}) as Result); + }); + + await peerTx.send({ jsonrpc: '2.0', id: 1, method: 'acme/known', params: {} }); + await flush(); + + expect(sent).toHaveLength(1); + expect((sent[0] as JSONRPCErrorResponse).error).toMatchObject({ + code: ProtocolErrorCode.InvalidParams, + message: 'Request is missing the required _meta envelope' + }); + }); +}); + +describe('inbound protocol-version mismatch (−32004): the error data lists every supported version', () => { + const flush = () => new Promise(resolve => setTimeout(resolve, 10)); + + test('a request classified for a protocol version this connection does not serve is rejected with the full supported list', async () => { + const supportedProtocolVersions = ['2025-11-25', '2025-06-18', '2025-03-26']; + const [peerTx, protocolTx] = InMemoryTransport.createLinkedPair(); + const sent: JSONRPCMessage[] = []; + peerTx.onmessage = message => void sent.push(message); + await peerTx.start(); + + const protocol = new TestProtocolImpl({ supportedProtocolVersions }); + const errors: Error[] = []; + protocol.onerror = error => void errors.push(error); + await protocol.connect(protocolTx); + + // Deliver a request whose transport-edge classification names a + // protocol version this connection does not serve. The rejection's + // `data.supported` must list every protocol version the receiver + // supports — not just the version the connection is on — so the peer + // can pick a mutually supported version from the error alone. + protocolTx.onmessage?.( + { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} } as JSONRPCMessage, + // The in-memory transport's onmessage declares the narrower + // pre-classification extra type; the protocol layer reads the + // full MessageExtraInfo (same cast as the era-gate suite). + { classification: { era: 'modern' } } as never + ); + await flush(); + + expect(sent).toHaveLength(1); + const error = (sent[0] as JSONRPCErrorResponse).error as { + code: number; + message: string; + data?: { supported?: string[]; requested?: string }; + }; + expect(error.code).toBe(-32004); + expect(error.message).toContain('Unsupported protocol version'); + expect(error.data?.supported).toEqual(supportedProtocolVersions); + expect(error.data?.requested).toBe('2026-07-28'); + + await protocol.close(); + }); +}); diff --git a/packages/core/test/shared/protocolClassifyInboundHook.test.ts b/packages/core/test/shared/protocolClassifyInboundHook.test.ts new file mode 100644 index 0000000000..23bb7cf6ce --- /dev/null +++ b/packages/core/test/shared/protocolClassifyInboundHook.test.ts @@ -0,0 +1,255 @@ +/** + * The protocol-layer classification consult (`Protocol._classifyInbound`): + * + * - B-2 pin: when the transport supplied an edge classification, the hook is + * NEVER consulted — the edge classification always wins. + * - The base implementation returns `undefined`, so unclassified traffic on + * a default instance keeps today's dispatch path byte-identically. + * - A hook classification populates the `MessageExtraInfo.classification` + * carrier and, on an UNBOUND instance (no negotiated protocol version), + * selects the wire era for that one message (per-message era on long-lived + * dual-era channels). On a BOUND instance it is validated exactly like an + * edge classification (mismatch ⇒ −32004 for requests, drop for + * notifications). + * - Returning `'drop'` discards the message without writing any response. + */ +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol, setNegotiatedProtocolVersion } from '../../src/shared/protocol.js'; +import type { + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResultResponse, + MessageClassification, + MessageExtraInfo, + Result +} from '../../src/types/index.js'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + isJSONRPCErrorResponse, + isJSONRPCResultResponse, + PROTOCOL_VERSION_META_KEY +} from '../../src/types/index.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; + +const MODERN = '2026-07-28'; + +const modernEnvelope = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'hook-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +class HookedProtocol extends Protocol { + /** Messages the hook was consulted for (in order). */ + consulted: Array = []; + /** What the hook answers; `undefined` keeps the base behavior. */ + verdict: ((message: JSONRPCRequest | JSONRPCNotification) => MessageClassification | 'drop' | undefined) | undefined; + /** The MessageExtraInfo handed to buildContext for the last dispatched request. */ + lastExtra: MessageExtraInfo | undefined; + + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): BaseContext { + this.lastExtra = transportInfo; + return ctx; + } + + protected override _classifyInbound(message: JSONRPCRequest | JSONRPCNotification): MessageClassification | 'drop' | undefined { + this.consulted.push(message); + return this.verdict?.(message); + } +} + +class BaseProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +const flush = () => new Promise(resolve => setTimeout(resolve, 10)); + +async function wire>(protocol: T) { + const [peerTx, protocolTx] = InMemoryTransport.createLinkedPair(); + const sent: JSONRPCMessage[] = []; + peerTx.onmessage = message => void sent.push(message); + await peerTx.start(); + const errors: Error[] = []; + protocol.onerror = error => void errors.push(error); + await protocol.connect(protocolTx); + return { peerTx, protocolTx, sent, errors }; +} + +describe('B-2: an edge classification always wins', () => { + it('never consults the hook for a message that already carries a classification', async () => { + const protocol = new HookedProtocol(); + protocol.verdict = () => ({ era: 'modern', revision: MODERN }); + const { protocolTx, sent } = await wire(protocol); + + protocolTx.onmessage?.( + { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} } as JSONRPCMessage, + // The in-memory transport's onmessage declares the narrower + // pre-classification extra type; the protocol layer reads the + // full MessageExtraInfo (same cast as the era-gate suite). + { classification: { era: 'legacy' } } as never + ); + await flush(); + + expect(protocol.consulted).toHaveLength(0); + // The edge classification (legacy) matches the unbound instance era, + // so the request proceeds to today's path: no handler ⇒ −32601. + expect(sent).toHaveLength(1); + expect((sent[0] as JSONRPCErrorResponse).error.code).toBe(-32_601); + await protocol.close(); + }); + + it('consults the hook when the transport did not classify', async () => { + const protocol = new HookedProtocol(); + protocol.verdict = () => undefined; + const { peerTx, sent } = await wire(protocol); + + await peerTx.send({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); + await flush(); + + expect(protocol.consulted).toHaveLength(1); + expect(protocol.consulted[0]).toMatchObject({ method: 'tools/list' }); + // `undefined` keeps today's path: no handler ⇒ −32601, no classification carrier. + expect(sent).toHaveLength(1); + expect((sent[0] as JSONRPCErrorResponse).error.code).toBe(-32_601); + await protocol.close(); + }); +}); + +describe("base implementation (no override) keeps today's dispatch", () => { + it('serves unclassified legacy traffic identically: handler runs, result is not stamped with 2026 wire fields', async () => { + const protocol = new BaseProtocol(); + protocol.setRequestHandler('tools/list', () => ({ tools: [] })); + const { peerTx, sent } = await wire(protocol); + + await peerTx.send({ jsonrpc: '2.0', id: 7, method: 'tools/list', params: {} }); + await flush(); + + expect(sent).toHaveLength(1); + const response = sent[0] as JSONRPCResultResponse; + expect(isJSONRPCResultResponse(response)).toBe(true); + expect(response.result).toEqual({ tools: [] }); + expect(JSON.stringify(response)).not.toContain('resultType'); + await protocol.close(); + }); +}); + +describe('per-message era on an unbound instance (long-lived dual-era channels)', () => { + it('a hook classification of modern serves the message on the 2026 era: envelope honored, result stamped', async () => { + const protocol = new HookedProtocol(); + protocol.verdict = message => (message.method === 'initialize' ? { era: 'legacy' } : { era: 'modern', revision: MODERN }); + protocol.setRequestHandler('tools/list', () => ({ tools: [] })); + const { peerTx, sent } = await wire(protocol); + + await peerTx.send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: { _meta: modernEnvelope } }); + await flush(); + + expect(sent).toHaveLength(1); + const response = sent[0] as JSONRPCResultResponse; + expect(isJSONRPCResultResponse(response)).toBe(true); + expect((response.result as { resultType?: string }).resultType).toBe('complete'); + // The carrier was populated and reached the handler context. + expect(protocol.lastExtra?.classification).toEqual({ era: 'modern', revision: MODERN }); + await protocol.close(); + }); + + it('a hook classification of legacy answers a 2026-only spec method with a plain −32601 (era gate by registry absence)', async () => { + const protocol = new HookedProtocol(); + protocol.verdict = () => ({ era: 'legacy' }); + // Even an installed handler cannot shadow the era gate. + protocol.setRequestHandler('server/discover', { params: z.looseObject({}) }, () => ({}) as Result); + const { peerTx, sent } = await wire(protocol); + + await peerTx.send({ jsonrpc: '2.0', id: 3, method: 'server/discover', params: {} }); + await flush(); + + expect(sent).toHaveLength(1); + const response = sent[0] as JSONRPCErrorResponse; + expect(isJSONRPCErrorResponse(response)).toBe(true); + expect(response.error).toEqual({ code: -32_601, message: 'Method not found' }); + await protocol.close(); + }); +}); + +describe('hook classification on a BOUND instance is validated like an edge classification', () => { + it('a legacy-classified request on a modern-bound instance answers −32004 with the supported list', async () => { + const protocol = new HookedProtocol(); + protocol.verdict = () => ({ era: 'legacy' }); + const { peerTx, sent } = await wire(protocol); + setNegotiatedProtocolVersion(protocol, MODERN); + + await peerTx.send({ jsonrpc: '2.0', id: 4, method: 'tools/list', params: {} }); + await flush(); + + expect(sent).toHaveLength(1); + const error = (sent[0] as JSONRPCErrorResponse).error as { code: number; data?: { supported?: string[] } }; + expect(error.code).toBe(-32_004); + expect(Array.isArray(error.data?.supported)).toBe(true); + await protocol.close(); + }); + + it('a legacy-classified notification on a modern-bound instance is dropped (no handler invocation, no response)', async () => { + const protocol = new HookedProtocol(); + protocol.verdict = () => ({ era: 'legacy' }); + let invoked = 0; + protocol.fallbackNotificationHandler = async () => { + invoked += 1; + }; + const { peerTx, sent, errors } = await wire(protocol); + setNegotiatedProtocolVersion(protocol, MODERN); + + await peerTx.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + await flush(); + + expect(invoked).toBe(0); + expect(sent).toHaveLength(0); + expect(errors.length).toBeGreaterThan(0); + await protocol.close(); + }); +}); + +describe("'drop' verdict", () => { + it('discards an inbound request without writing any response and surfaces it via onerror', async () => { + const protocol = new HookedProtocol(); + protocol.verdict = () => 'drop'; + protocol.setRequestHandler('tools/list', () => ({ tools: [] })); + const { peerTx, sent, errors } = await wire(protocol); + + await peerTx.send({ jsonrpc: '2.0', id: 5, method: 'tools/list', params: {} }); + await flush(); + + expect(sent).toHaveLength(0); + expect(errors.some(error => error.message.includes('Dropped inbound request'))).toBe(true); + await protocol.close(); + }); + + it('discards an inbound notification without dispatching it', async () => { + const protocol = new HookedProtocol(); + protocol.verdict = () => 'drop'; + let invoked = 0; + protocol.fallbackNotificationHandler = async () => { + invoked += 1; + }; + const { peerTx, sent } = await wire(protocol); + + await peerTx.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + await flush(); + + expect(invoked).toBe(0); + expect(sent).toHaveLength(0); + await protocol.close(); + }); +}); diff --git a/packages/core/test/shared/protocolEras.test.ts b/packages/core/test/shared/protocolEras.test.ts new file mode 100644 index 0000000000..c01c97fdad --- /dev/null +++ b/packages/core/test/shared/protocolEras.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'vitest'; + +import { + FIRST_MODERN_PROTOCOL_VERSION, + isModernProtocolVersion, + legacyProtocolVersions, + modernProtocolVersions, + SUPPORTED_MODERN_PROTOCOL_VERSIONS +} from '../../src/shared/protocolEras.js'; +import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from '../../src/types/constants.js'; + +describe('protocol era helpers', () => { + test('every released (legacy-list) version is classified legacy', () => { + for (const version of SUPPORTED_PROTOCOL_VERSIONS) { + expect(isModernProtocolVersion(version)).toBe(false); + } + expect(legacyProtocolVersions(SUPPORTED_PROTOCOL_VERSIONS)).toEqual(SUPPORTED_PROTOCOL_VERSIONS); + expect(modernProtocolVersions(SUPPORTED_PROTOCOL_VERSIONS)).toEqual([]); + }); + + test('the 2026-07-28 revision and later are classified modern', () => { + expect(isModernProtocolVersion('2026-07-28')).toBe(true); + expect(isModernProtocolVersion('2027-01-01')).toBe(true); + expect(FIRST_MODERN_PROTOCOL_VERSION).toBe('2026-07-28'); + }); + + test('subsetting preserves the list preference order', () => { + const mixed = ['2026-07-28', LATEST_PROTOCOL_VERSION, '2025-06-18']; + expect(modernProtocolVersions(mixed)).toEqual(['2026-07-28']); + expect(legacyProtocolVersions(mixed)).toEqual([LATEST_PROTOCOL_VERSION, '2025-06-18']); + }); + + test('era-disjoint constants: the modern list never feeds the legacy initialize list', () => { + // Ordering guard (counter-offer leak, server.ts counter-offer site): the + // legacy SUPPORTED_PROTOCOL_VERSIONS constant must not contain modern + // revisions; modern negotiation reads SUPPORTED_MODERN_PROTOCOL_VERSIONS, + // which must contain only modern revisions. + expect(SUPPORTED_PROTOCOL_VERSIONS.some(isModernProtocolVersion)).toBe(false); + expect(SUPPORTED_MODERN_PROTOCOL_VERSIONS.every(isModernProtocolVersion)).toBe(true); + }); +}); diff --git a/packages/core/test/shared/rawResultTypeFirst.test.ts b/packages/core/test/shared/rawResultTypeFirst.test.ts new file mode 100644 index 0000000000..51fb40211f --- /dev/null +++ b/packages/core/test/shared/rawResultTypeFirst.test.ts @@ -0,0 +1,216 @@ +/** + * Raw-first result discrimination (V-1) — relocated to its structural home: + * step 1 of the era codec's `decodeResult`, BEFORE any schema validation + * (Q1 increment 2; previously a funnel insertion in `_requestWithSchema`). + * + * The postures are ERA-SCOPED (Q1-SD3): + * + * 2026 era (the connection negotiated '2026-07-28'): + * - `resultType` is REQUIRED. Absent → typed error NAMING the spec + * violation (the absent⇒complete bridge is scoped to earlier-revision + * servers and deliberately NOT extended to modern traffic). + * - `input_required` → discriminated driver payload, surfaced as a typed + * local error until the multi-round-trip driver (M4.1) consumes it. + * - unknown kinds → invalid, no retry. Non-string → invalid. + * - `'complete'` → wire-exact parse (resultType present) then lift. + * + * 2025 era (any legacy version / unbound instance): + * - `resultType` is FOREIGN vocabulary → strip-on-lift (tolerate-and-drop, + * whatever its value); validation then judges the actual content. This is + * a deliberate behavior migration from the era-blind funnel arm (ledgered; + * changeset: codec-split-wire-break). + * + * Either way, the V-1 invariant holds: a non-complete body can NEVER be + * masked into a hollow success by a tolerant result schema. + */ +import { describe, expect, test } from 'vitest'; + +import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol, setNegotiatedProtocolVersion } from '../../src/shared/protocol.js'; +import type { JSONRPCRequest } from '../../src/types/index.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; + +class TestProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +/** Wire a protocol whose peer answers every request with the given raw result body. */ +async function wireWithRawResult(rawResult: unknown, era?: '2026-07-28'): Promise { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = message => { + const request = message as JSONRPCRequest; + void serverTx.send({ jsonrpc: '2.0', id: request.id, result: rawResult } as Parameters[0]); + }; + await serverTx.start(); + const protocol = new TestProtocol(); + await protocol.connect(clientTx); + if (era) setNegotiatedProtocolVersion(protocol, era); + return protocol; +} + +const INPUT_REQUIRED_BODY = { + resultType: 'input_required', + inputRequests: { 'elicit-1': { method: 'elicitation/create', params: { mode: 'form', message: 'Name?' } } }, + requestState: 'opaque' +}; + +async function settle(protocol: TestProtocol): Promise<{ resolved: unknown } | { rejected: unknown }> { + return protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }).then( + result => ({ resolved: result as unknown }), + error => ({ rejected: error as unknown }) + ); +} + +describe('raw-first resultType discrimination — 2026 era (codec decode step 1)', () => { + test('an input_required body surfaces the discriminated kind, never an empty-content success', async () => { + const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY, '2026-07-28'); + const outcome = await settle(protocol); + + expect('resolved' in outcome, 'must not resolve as a success').toBe(false); + const rejection = (outcome as { rejected: unknown }).rejected; + expect(rejection).toBeInstanceOf(SdkError); + const typed = rejection as SdkError; + expect(typed.code).toBe(SdkErrorCode.UnsupportedResultType); + expect(typed.data).toMatchObject({ resultType: 'input_required', method: 'tools/call' }); + + await protocol.close(); + }); + + test('an unrecognized resultType kind is invalid — surfaced, no retry', async () => { + const protocol = await wireWithRawResult({ resultType: 'mystery-kind', content: [] }, '2026-07-28'); + const outcome = await settle(protocol); + + expect('rejected' in outcome).toBe(true); + const rejection = (outcome as { rejected: unknown }).rejected as SdkError; + expect(rejection).toBeInstanceOf(SdkError); + expect(rejection.code).toBe(SdkErrorCode.UnsupportedResultType); + expect(rejection.data).toMatchObject({ resultType: 'mystery-kind' }); + + await protocol.close(); + }); + + test('ABSENT resultType is a spec violation on the modern leg — typed error naming it (Q1-SD3 i)', async () => { + // The absent⇒complete bridge is scoped to earlier-revision servers; + // a 2026-negotiated peer that omits the REQUIRED member is broken. + const protocol = await wireWithRawResult({ content: [{ type: 'text', text: 'looks fine' }] }, '2026-07-28'); + const outcome = await settle(protocol); + + expect('rejected' in outcome).toBe(true); + const rejection = (outcome as { rejected: unknown }).rejected as SdkError; + expect(rejection).toBeInstanceOf(SdkError); + expect(rejection.code).toBe(SdkErrorCode.InvalidResult); + expect(rejection.message).toContain('missing required resultType'); + expect(rejection.data).toMatchObject({ method: 'tools/call', violation: 'missing-resultType' }); + + await protocol.close(); + }); + + test('a non-string resultType can never surface as a success', async () => { + const protocol = await wireWithRawResult({ resultType: 42, content: [] }, '2026-07-28'); + const outcome = await settle(protocol); + + expect('rejected' in outcome).toBe(true); + const rejection = (outcome as { rejected: unknown }).rejected as SdkError; + expect(rejection).toBeInstanceOf(SdkError); + expect(rejection.code).toBe(SdkErrorCode.InvalidResult); + expect(rejection.data).toMatchObject({ resultType: 42 }); + + await protocol.close(); + }); + + test("resultType 'complete' is consumed: the result resolves without the wire member", async () => { + const protocol = await wireWithRawResult({ resultType: 'complete', content: [{ type: 'text', text: 'done' }] }, '2026-07-28'); + + const result = await protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }); + expect(result.content).toEqual([{ type: 'text', text: 'done' }]); + expect('resultType' in result).toBe(false); + + await protocol.close(); + }); +}); + +describe('raw-first resultType handling — 2025 era (strip-on-lift, Q1-SD3 ii)', () => { + test('a foreign input_required body is stripped, then validation judges the content — never a silent success', async () => { + // BEHAVIOR MIGRATION (ledgered): pre-split, the era-blind funnel arm + // rejected this with UnsupportedResultType on every leg. On the 2025 + // era resultType carries no meaning — the ruled posture strips the + // foreign key and lets validation decide. The body has no content, + // so it fails the (default-free) tools/call result schema LOUDLY — + // the V-1 invariant (never a hollow success) holds. + const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY); + const outcome = await settle(protocol); + + expect('resolved' in outcome, 'must not resolve as a success').toBe(false); + const rejection = (outcome as { rejected: unknown }).rejected as SdkError; + expect(rejection).toBeInstanceOf(SdkError); + expect(rejection.code).toBe(SdkErrorCode.InvalidResult); + + await protocol.close(); + }); + + test('strip-on-lift is VALUE-BLIND: a foreign input_required WITH a valid body resolves, member stripped', async () => { + // The strip keys on the member's PRESENCE, never its value — even the + // driver kind is foreign vocabulary on this era. With a valid body + // the request resolves; the stripped key never surfaces. (The + // sibling test above covers the invalid-body arm: there the strip + // also runs, and validation then rejects on the actual content.) + const protocol = await wireWithRawResult({ resultType: 'input_required', content: [{ type: 'text', text: 'ok' }] }); + + const result = await protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }); + expect(result.content).toEqual([{ type: 'text', text: 'ok' }]); + expect('resultType' in result).toBe(false); + + await protocol.close(); + }); + + test('a foreign non-string resultType is stripped; an otherwise-valid result resolves without it', async () => { + const protocol = await wireWithRawResult({ resultType: 42, content: [{ type: 'text', text: 'ok' }] }); + + const result = await protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }); + expect(result.content).toEqual([{ type: 'text', text: 'ok' }]); + expect('resultType' in result).toBe(false); + + await protocol.close(); + }); + + test("resultType 'complete' on a strict empty result still parses (stripped before validation)", async () => { + const protocol = await wireWithRawResult({ resultType: 'complete' }); + + const result = await protocol.request({ method: 'ping' }); + expect(result).toEqual({}); + + await protocol.close(); + }); + + test('absent resultType is untouched 2025-era behavior (siblings kept)', async () => { + const protocol = await wireWithRawResult({ content: [{ type: 'text', text: 'plain' }], extraSibling: 'kept' }); + + const result = await protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }); + expect(result.content).toEqual([{ type: 'text', text: 'plain' }]); + expect((result as Record).extraSibling).toBe('kept'); + + await protocol.close(); + }); +}); + +describe('decode step 2 — the wire-exact schema lookup is own-key only', () => { + test("a prototype-chain method name (e.g. 'constructor') skips the wire-exact parse instead of throwing", async () => { + const { rev2026Codec } = await import('../../src/wire/rev2026-07-28/codec.js'); + // A bare object-prototype hit would surface Function (not a schema) + // and throw a TypeError out of the decode hop. The lookup must treat + // non-own keys exactly like unknown methods: no wire-exact parse, + // straight to the lift. + const decoded = rev2026Codec.decodeResult('constructor', { resultType: 'complete', anything: 'kept' }); + expect(decoded.kind).toBe('complete'); + if (decoded.kind === 'complete') { + expect((decoded.result as Record).anything).toBe('kept'); + expect('resultType' in decoded.result).toBe(false); + } + }); +}); diff --git a/packages/core/test/shared/typedMapAlignment.test.ts b/packages/core/test/shared/typedMapAlignment.test.ts new file mode 100644 index 0000000000..1cd836d3db --- /dev/null +++ b/packages/core/test/shared/typedMapAlignment.test.ts @@ -0,0 +1,139 @@ +/** + * Runtime/typed result-map alignment. + * + * `getResultSchema`'s typed overload asserts `z.ZodType`, + * so the runtime map must not be looser than the typed map: no task-result + * union members on `tools/call` / `sampling/createMessage` / + * `elicitation/create` (ResultTypeMap types them plain), and no `tasks/*` + * entries at all (the task methods are 2025-11-25 wire vocabulary outside + * `RequestMethod`). + * + * The behavioral consequence for a generic `request()` caller facing a + * 2025-era task server: a `CreateTaskResult` body can no longer parse via a + * union member and surface mis-typed (a `CreateTaskResult` typed as + * `CreateMessageResult`/`ElicitResult`). Where the method's result schema + * rejects the body it now fails as a typed invalid-result error. This client + * cannot drive tasks; a typed error is the correct surface, not a result + * whose static type lies. + */ +import { describe, expect, test } from 'vitest'; + +import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol } from '../../src/shared/protocol.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; +import type { JSONRPCRequest } from '../../src/types/index.js'; +// Post-relocation home (Q1 increment-2 step 1): the runtime registries live +// behind the per-era wire-codec interface now. +import { getResultSchema } from '../../src/wire/rev2025-11-25/registry.js'; + +class TestProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +/** A well-formed 2025-11-25 `CreateTaskResult` body. */ +const CREATE_TASK_RESULT_BODY = { + task: { + taskId: 'task-1', + status: 'working', + ttl: 60_000, + createdAt: '2025-11-25T00:00:00Z', + lastUpdatedAt: '2025-11-25T00:00:00Z', + pollInterval: 500 + } +}; + +/** Wire a protocol whose peer answers every request with the given raw result body. */ +async function wireWithRawResult(rawResult: unknown): Promise { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = message => { + const request = message as JSONRPCRequest; + void serverTx.send({ jsonrpc: '2.0', id: request.id, result: rawResult } as Parameters[0]); + }; + await serverTx.start(); + const protocol = new TestProtocol(); + await protocol.connect(clientTx); + return protocol; +} + +describe('task-shaped result bodies against the narrowed runtime map', () => { + test('sampling/createMessage: a CreateTaskResult body is a typed invalid-result error, not a mis-typed success', async () => { + // Before the narrowing, the union member parsed this body and handed + // it back TYPED as CreateMessageResult — a result whose static type + // lies. Now it fails the (plain) result schema locally. + const protocol = await wireWithRawResult(CREATE_TASK_RESULT_BODY); + + const outcome = await protocol.request({ method: 'sampling/createMessage', params: { messages: [], maxTokens: 1 } }).then( + result => ({ resolved: result as unknown }), + error => ({ rejected: error as unknown }) + ); + + expect('resolved' in outcome, 'must not resolve as a success').toBe(false); + const rejection = (outcome as { rejected: unknown }).rejected; + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.InvalidResult); + + await protocol.close(); + }); + + test('elicitation/create: a CreateTaskResult body is a typed invalid-result error, not a mis-typed success', async () => { + const protocol = await wireWithRawResult(CREATE_TASK_RESULT_BODY); + + const rejection = await protocol + .request({ method: 'elicitation/create', params: { mode: 'form', message: 'Name?', requestedSchema: { type: 'object' } } }) + .catch((error: unknown) => error); + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.InvalidResult); + + await protocol.close(); + }); + + test('tools/call: a CreateTaskResult body is now a typed invalid-result error too (content-default removal flip)', async () => { + // FLIPPED PIN (Q1 increment 2, ledgered with the content-default + // removal — changeset: codec-split-wire-break). The previous "Honest + // pin, not an endorsement" recorded that CallToolResultSchema's + // content.default([]) swallowed ANY object — including a task body — + // as a content-empty success, which made the old union member + // unreachable and the map narrowing observationally invisible for + // tools/call. With `content` now REQUIRED at the wire boundary the + // masking surface is gone: a task body has no `content`, fails the + // plain schema, and surfaces as the same typed invalid-result error + // as sampling/elicit. The result-schema-strictness question the old + // pin deferred is hereby resolved: loud rejection. + const protocol = await wireWithRawResult(CREATE_TASK_RESULT_BODY); + + const rejection = await protocol + .request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }) + .catch((error: unknown) => error); + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.InvalidResult); + + await protocol.close(); + }); +}); + +describe('tasks/* entries are gone from the runtime result map', () => { + test('getResultSchema returns undefined for every task method', () => { + for (const method of ['tasks/get', 'tasks/result', 'tasks/list', 'tasks/cancel']) { + expect(getResultSchema(method), method).toBeUndefined(); + } + }); + + test('a generic request() for a task method demands an explicit schema', async () => { + // The typed overload already excluded task methods; the runtime map + // entries were typed-unreachable leftovers. Without them, the + // explicit-schema overload is the one (intentional) interop path. + const protocol = await wireWithRawResult({}); + + expect(() => protocol.request({ method: 'tasks/get', params: { taskId: 't-1' } } as never)).toThrow( + /'tasks\/get' is not a spec method; pass a result schema/ + ); + + await protocol.close(); + }); +}); diff --git a/packages/core/test/shared/wireOnlyLift.test.ts b/packages/core/test/shared/wireOnlyLift.test.ts new file mode 100644 index 0000000000..32a8eb5302 --- /dev/null +++ b/packages/core/test/shared/wireOnlyLift.test.ts @@ -0,0 +1,329 @@ +/** + * Envelope lift, two-sided: wire-only material is hidden from handlers AND + * (for requests) reaches the protocol layer un-deleted. + * + * Hide set, per message kind. Requests: the reserved + * `io.modelcontextprotocol/*` envelope `_meta` keys and the multi-round-trip + * retry fields (`inputResponses`/`requestState`) — the envelope is readable + * via `ctx.mcpReq.envelope` and the retry fields via + * `ctx.mcpReq.inputResponses`/`.requestState`. Notifications: ONLY the + * envelope `_meta` keys (the spec reserves the retry params names on + * client-initiated requests, not notifications), and there is no + * per-notification ctx, so the lifted envelope keys are dropped rather than + * surfaced. Under 2026-era traffic, handler params must be byte-equal to the + * 2025-era shape of the same call; traffic without wire-only material passes + * through untouched (same reference — no cloning on the hot path). + */ +import { describe, expect, expectTypeOf, test } from 'vitest'; +import * as z from 'zod/v4'; + +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol } from '../../src/shared/protocol.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; +import type { JSONRPCMessage, JSONRPCRequest, RequestMetaEnvelope, Result } from '../../src/types/index.js'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + LOG_LEVEL_META_KEY, + PROTOCOL_VERSION_META_KEY, + RELATED_TASK_META_KEY +} from '../../src/types/index.js'; + +class TestProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: '2026-07-28', + [CLIENT_INFO_META_KEY]: { name: 'modern-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: { elicitation: {} }, + [LOG_LEVEL_META_KEY]: 'info' +}; + +interface Wired { + receiver: TestProtocol; + peer: InMemoryTransport; + responses: JSONRPCMessage[]; +} + +async function wireReceiver(setup: (receiver: TestProtocol) => void): Promise { + const [peer, receiverTx] = InMemoryTransport.createLinkedPair(); + const receiver = new TestProtocol(); + setup(receiver); + await receiver.connect(receiverTx); + const responses: JSONRPCMessage[] = []; + peer.onmessage = message => void responses.push(message); + await peer.start(); + return { receiver, peer, responses }; +} + +const flush = () => new Promise(resolve => setTimeout(resolve, 20)); + +describe('envelope lift on inbound requests', () => { + test('handler params are byte-equal to the 2025 shape; envelope readable via ctx', async () => { + let seenRequest: unknown; + let seenCtx: BaseContext | undefined; + const { peer } = await wireReceiver(receiver => { + receiver.setRequestHandler('tools/call', (request, ctx) => { + seenRequest = request; + seenCtx = ctx; + return { content: [] }; + }); + }); + + // A modern request: envelope keys ride _meta next to 2025-legal + // material (progressToken, related-task). + await peer.send({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'echo', + arguments: { text: 'hi' }, + _meta: { ...ENVELOPE, progressToken: 7, [RELATED_TASK_META_KEY]: { taskId: 't-1' } } + } + } as JSONRPCMessage); + await flush(); + + // Byte-equal to the 2025-era shape of the same call (the spec-method + // handler receives the schema-parsed {method, params} form). + expect(seenRequest).toEqual({ + method: 'tools/call', + params: { + name: 'echo', + arguments: { text: 'hi' }, + _meta: { progressToken: 7, [RELATED_TASK_META_KEY]: { taskId: 't-1' } } + } + }); + // ctx._meta mirrors the lifted _meta… + expect(seenCtx?.mcpReq._meta).toEqual({ progressToken: 7, [RELATED_TASK_META_KEY]: { taskId: 't-1' } }); + // …and the envelope is surfaced verbatim, un-deleted. + expect(seenCtx?.mcpReq.envelope).toEqual(ENVELOPE); + }); + + test('a partial envelope (a subset of the reserved keys) surfaces as received and types as Partial', async () => { + // A one-revision-old peer may legally send only some reserved keys + // (e.g. just the log-level opt-in). The lift surfaces whatever was + // present, and the ctx slot's type says so: every member is optional. + let seenCtx: BaseContext | undefined; + const { peer } = await wireReceiver(receiver => { + receiver.setRequestHandler('tools/call', (_request, ctx) => { + seenCtx = ctx; + return { content: [] }; + }); + }); + + await peer.send({ + jsonrpc: '2.0', + id: 7, + method: 'tools/call', + params: { name: 'echo', arguments: {}, _meta: { [LOG_LEVEL_META_KEY]: 'debug' } } + } as JSONRPCMessage); + await flush(); + + expect(seenCtx?.mcpReq.envelope).toEqual({ [LOG_LEVEL_META_KEY]: 'debug' }); + // The slot is Partial: a key the request did not + // carry reads as possibly-undefined — there is no claim that the + // required envelope members exist (requiredness is enforced per + // request at dispatch time, not by the lift). + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf(seenCtx!.mcpReq.envelope![PROTOCOL_VERSION_META_KEY]).toEqualTypeOf(); + expect(seenCtx?.mcpReq.envelope?.[PROTOCOL_VERSION_META_KEY]).toBeUndefined(); + }); + + test('a _meta that holds only envelope keys disappears entirely (exact 2025 shape)', async () => { + let seenRequest: unknown; + const { peer } = await wireReceiver(receiver => { + receiver.setRequestHandler('tools/call', request => { + seenRequest = request; + return { content: [] }; + }); + }); + + await peer.send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'echo', arguments: {}, _meta: { ...ENVELOPE } } + } as JSONRPCMessage); + await flush(); + + expect(seenRequest).toEqual({ + method: 'tools/call', + params: { name: 'echo', arguments: {} } + }); + }); + + test('retry fields are hidden from handler params and reach ctx un-deleted', async () => { + let seenRequest: unknown; + let seenCtx: BaseContext | undefined; + const { peer } = await wireReceiver(receiver => { + receiver.setRequestHandler('tools/call', (request, ctx) => { + seenRequest = request; + seenCtx = ctx; + return { content: [] }; + }); + }); + + const inputResponses = { 'req-1': { action: 'accept', content: { name: 'octocat' } } }; + await peer.send({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'echo', arguments: {}, inputResponses, requestState: 'opaque-state-token' } + } as JSONRPCMessage); + await flush(); + + expect(seenRequest).toEqual({ + method: 'tools/call', + params: { name: 'echo', arguments: {} } + }); + expect(seenCtx?.mcpReq.inputResponses).toEqual(inputResponses); + expect(seenCtx?.mcpReq.requestState).toBe('opaque-state-token'); + }); + + test('the custom-method (3-arg) path also surfaces the envelope via ctx', async () => { + let seenParams: unknown; + let seenCtx: BaseContext | undefined; + const { peer, responses } = await wireReceiver(receiver => { + receiver.setRequestHandler('acme/search', { params: z.object({ query: z.string() }) }, (params, ctx) => { + seenParams = params; + seenCtx = ctx; + return { hits: [] }; + }); + }); + + await peer.send({ + jsonrpc: '2.0', + id: 4, + method: 'acme/search', + params: { query: 'mcp', _meta: { ...ENVELOPE } } + } as JSONRPCMessage); + await flush(); + + expect(seenParams).toEqual({ query: 'mcp' }); + expect(seenCtx?.mcpReq.envelope).toEqual(ENVELOPE); + expect(responses).toHaveLength(1); + }); + + test('the fallback request handler receives the lifted request too', async () => { + let seenRequest: JSONRPCRequest | undefined; + const { peer } = await wireReceiver(receiver => { + receiver.fallbackRequestHandler = request => { + seenRequest = request; + return Promise.resolve({} as Result); + }; + }); + + await peer.send({ + jsonrpc: '2.0', + id: 5, + method: 'vendor/anything', + params: { value: 1, _meta: { ...ENVELOPE }, requestState: 's' } + } as JSONRPCMessage); + await flush(); + + expect(seenRequest?.params).toEqual({ value: 1 }); + }); + + test('2025-era requests pass through untouched (same reference, no ctx slots)', async () => { + let seenRequest: JSONRPCRequest | undefined; + let seenCtx: BaseContext | undefined; + const { peer } = await wireReceiver(receiver => { + receiver.fallbackRequestHandler = (request, ctx) => { + seenRequest = request; + seenCtx = ctx; + return Promise.resolve({} as Result); + }; + }); + + const legacy = { + jsonrpc: '2.0', + id: 6, + method: 'vendor/legacy', + params: { value: 2, _meta: { progressToken: 9 } } + } as JSONRPCMessage; + await peer.send(legacy); + await flush(); + + // Identity preserved: the lift allocates nothing for clean traffic. + expect(seenRequest).toBe(legacy); + expect(seenCtx?.mcpReq.envelope).toBeUndefined(); + expect(seenCtx?.mcpReq.inputResponses).toBeUndefined(); + expect(seenCtx?.mcpReq.requestState).toBeUndefined(); + }); +}); + +describe('envelope lift on inbound notifications', () => { + test('notification handlers never see the reserved envelope keys', async () => { + let seenParams: unknown; + let seenNotification: unknown; + const { peer } = await wireReceiver(receiver => { + receiver.setNotificationHandler('vendor/changed', { params: z.object({ value: z.number() }) }, (params, notification) => { + seenParams = params; + seenNotification = notification; + }); + }); + + await peer.send({ + jsonrpc: '2.0', + method: 'vendor/changed', + params: { value: 42, _meta: { ...ENVELOPE, progressToken: 1 } } + } as JSONRPCMessage); + await flush(); + + expect(seenParams).toEqual({ value: 42 }); + // The raw notification handed to the handler is the lifted one: + // _meta retains only non-reserved material. + expect((seenNotification as { params?: { _meta?: unknown } }).params?._meta).toEqual({ progressToken: 1 }); + }); + + test('top-level params named like the retry fields reach notification handlers intact', async () => { + // The spec reserves `inputResponses`/`requestState` on + // client-initiated REQUESTS only. A vendor notification is free to + // use those names as ordinary params — the lift must not touch them + // (notifications have no ctx, so a delete would be unrecoverable). + let seenParams: unknown; + const { peer } = await wireReceiver(receiver => { + receiver.setNotificationHandler( + 'vendor/stateChanged', + { params: z.looseObject({ requestState: z.string() }) }, + params => void (seenParams = params) + ); + }); + + await peer.send({ + jsonrpc: '2.0', + method: 'vendor/stateChanged', + params: { requestState: 'app-domain-value', inputResponses: { poll: 'yes' }, _meta: { ...ENVELOPE } } + } as JSONRPCMessage); + await flush(); + + // Envelope keys lifted; the retry-named top-level params untouched. + expect(seenParams).toEqual({ requestState: 'app-domain-value', inputResponses: { poll: 'yes' } }); + }); + + test('the fallback notification handler receives the lifted notification', async () => { + let seen: unknown; + const { peer } = await wireReceiver(receiver => { + receiver.fallbackNotificationHandler = notification => { + seen = notification; + return Promise.resolve(); + }; + }); + + await peer.send({ + jsonrpc: '2.0', + method: 'vendor/ping', + params: { _meta: { ...ENVELOPE } } + } as JSONRPCMessage); + await flush(); + + expect((seen as { params?: unknown }).params).toEqual({}); + }); +}); diff --git a/packages/core/test/spec.types.2025-11-25.test.ts b/packages/core/test/spec.types.2025-11-25.test.ts index bf0903cd1a..76d6d7bfab 100644 --- a/packages/core/test/spec.types.2025-11-25.test.ts +++ b/packages/core/test/spec.types.2025-11-25.test.ts @@ -1,19 +1,50 @@ /** - * Compares the SDK's types against the frozen 2025-11-25 release schema - * (spec.types.2025-11-25.ts). The 2026-07-28 comparison lives in + * Per-revision parity against the FROZEN 2025-11-25 release schema + * (spec.types.2025-11-25.ts). The draft comparison lives in * spec.types.2026-07-28.test.ts. * - * This contains: - * - Static type checks to verify the Spec's types are compatible with the SDK's types - * (mutually assignable — no type-level workarounds should be needed) - * - Runtime checks to verify each Spec type has a static check - * (note: a few don't have SDK types, see MISSING_SDK_TYPES below) + * Q1 increment 2 retired the 20 `@ts-expect-error` affordances this file + * used to carry: where the neutral public types deliberately follow the + * 2026-07-28 typing (the shared-tier adjudications), the comparisons now + * target the 2025-era WIRE-VIEW types (`wire/rev2025-11-25/wireTypes.ts`), + * which restate the anchor shape exactly and document each adjudication in + * one place. Zero affordances remain: every check below is exact, both + * directions, and the key-parity pins include the previously-suppressed + * names (PromptArgument/PromptReference `title`, the capabilities key sets). */ import fs from 'node:fs'; import path from 'node:path'; import type * as SpecTypes from '../src/types/spec.types.2025-11-25.js'; import type * as SDKTypes from '../src/types/index.js'; +// The era-faithful 2025 wire role unions (Q1 increment 2): the NEUTRAL role +// aggregates no longer carry task vocabulary — the 2025-era wire module does. +// Role-union comparisons against this FROZEN revision's anchor therefore +// target the wire-era artifacts. +import type * as Wire2025 from '../src/wire/rev2025-11-25/schemas.js'; +import type { + Wire2025ClientCapabilities, + Wire2025ClientRequestView, + Wire2025CreateMessageRequest, + Wire2025CreateMessageRequestParams, + Wire2025InitializeRequest, + Wire2025InitializeRequestParams, + Wire2025InitializeResult, + Wire2025ListToolsResult, + Wire2025PromptArgument, + Wire2025PromptReference, + Wire2025ServerCapabilities, + Wire2025ServerRequestView, + Wire2025Tool +} from '../src/wire/rev2025-11-25/wireTypes.js'; +import type * as z4 from 'zod/v4'; + +type Wire2025ClientRequest = z4.infer; +type Wire2025ClientNotification = z4.infer; +type Wire2025ClientResult = z4.infer; +type Wire2025ServerRequest = z4.infer; +type Wire2025ServerNotification = z4.infer; +type Wire2025ServerResult = z4.infer; /* eslint-disable @typescript-eslint/no-unused-vars */ @@ -36,8 +67,7 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - InitializeRequestParams: (sdk: SDKTypes.InitializeRequestParams, spec: SpecTypes.InitializeRequestParams) => { - // @ts-expect-error 2025-11-25 types capabilities.experimental values as `object`; the SDK follows the 2026-07-28 schema's JSONObject + InitializeRequestParams: (sdk: Wire2025InitializeRequestParams, spec: SpecTypes.InitializeRequestParams) => { sdk = spec; spec = sdk; }, @@ -87,10 +117,8 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - CreateMessageRequestParams: (sdk: SDKTypes.CreateMessageRequestParams, spec: SpecTypes.CreateMessageRequestParams) => { - // @ts-expect-error 2025-11-25 types `metadata` as `object`; the SDK follows the 2026-07-28 schema's JSONObject + CreateMessageRequestParams: (sdk: Wire2025CreateMessageRequestParams, spec: SpecTypes.CreateMessageRequestParams) => { sdk = spec; - // @ts-expect-error the SDK's JSONValue-typed tool inputSchema properties are not assignable to 2025-11-25's `object` spec = sdk; }, CompleteRequestParams: (sdk: SDKTypes.CompleteRequestParams, spec: SpecTypes.CompleteRequestParams) => { @@ -220,15 +248,15 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - ClientResult: (sdk: SDKTypes.ClientResult, spec: SpecTypes.ClientResult) => { + ClientResult: (sdk: Wire2025ClientResult, spec: SpecTypes.ClientResult) => { sdk = spec; spec = sdk; }, - ClientNotification: (sdk: WithJSONRPC, spec: SpecTypes.ClientNotification) => { + ClientNotification: (sdk: WithJSONRPC, spec: SpecTypes.ClientNotification) => { sdk = spec; spec = sdk; }, - ServerResult: (sdk: SDKTypes.ServerResult, spec: SpecTypes.ServerResult) => { + ServerResult: (sdk: Wire2025ServerResult, spec: SpecTypes.ServerResult) => { sdk = spec; spec = sdk; }, @@ -244,20 +272,16 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - Tool: (sdk: SDKTypes.Tool, spec: SpecTypes.Tool) => { - // @ts-expect-error 2025-11-25 types inputSchema/outputSchema properties as `object`; the SDK follows the 2026-07-28 schema's JSONValue + Tool: (sdk: Wire2025Tool, spec: SpecTypes.Tool) => { sdk = spec; - // @ts-expect-error the SDK's JSONValue-typed inputSchema/outputSchema properties are not assignable to 2025-11-25's `object` spec = sdk; }, ListToolsRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListToolsRequest) => { sdk = spec; spec = sdk; }, - ListToolsResult: (sdk: SDKTypes.ListToolsResult, spec: SpecTypes.ListToolsResult) => { - // @ts-expect-error 2025-11-25 vs 2026-07-28 Tool typing; see the Tool check above + ListToolsResult: (sdk: Wire2025ListToolsResult, spec: SpecTypes.ListToolsResult) => { sdk = spec; - // @ts-expect-error 2025-11-25 vs 2026-07-28 Tool typing; see the Tool check above spec = sdk; }, CallToolResult: (sdk: SDKTypes.CallToolResult, spec: SpecTypes.CallToolResult) => { @@ -476,48 +500,39 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - CreateMessageRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CreateMessageRequest) => { - // @ts-expect-error 2025-11-25 vs 2026-07-28 typing of params metadata/tools; see the CreateMessageRequestParams check above + CreateMessageRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CreateMessageRequest) => { sdk = spec; - // @ts-expect-error 2025-11-25 vs 2026-07-28 typing of params metadata/tools; see the CreateMessageRequestParams check above spec = sdk; }, - InitializeRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.InitializeRequest) => { - // @ts-expect-error 2025-11-25 types capabilities.experimental values as `object`; the SDK follows the 2026-07-28 schema's JSONObject + InitializeRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.InitializeRequest) => { sdk = spec; spec = sdk; }, - InitializeResult: (sdk: SDKTypes.InitializeResult, spec: SpecTypes.InitializeResult) => { - // @ts-expect-error 2025-11-25 types capabilities.experimental values as `object`; the SDK follows the 2026-07-28 schema's JSONObject + InitializeResult: (sdk: Wire2025InitializeResult, spec: SpecTypes.InitializeResult) => { sdk = spec; spec = sdk; }, - ClientCapabilities: (sdk: SDKTypes.ClientCapabilities, spec: SpecTypes.ClientCapabilities) => { - // @ts-expect-error 2025-11-25 types experimental/sampling/elicitation/tasks blobs as `object`; the SDK follows the 2026-07-28 schema's JSONObject + ClientCapabilities: (sdk: Wire2025ClientCapabilities, spec: SpecTypes.ClientCapabilities) => { sdk = spec; spec = sdk; }, - ServerCapabilities: (sdk: SDKTypes.ServerCapabilities, spec: SpecTypes.ServerCapabilities) => { - // @ts-expect-error 2025-11-25 types experimental/logging/completions/tasks blobs as `object`; the SDK follows the 2026-07-28 schema's JSONObject + ServerCapabilities: (sdk: Wire2025ServerCapabilities, spec: SpecTypes.ServerCapabilities) => { sdk = spec; spec = sdk; }, - ClientRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ClientRequest) => { - // @ts-expect-error 2025-11-25 types capabilities.experimental values as `object` (via the InitializeRequest member); the SDK follows the 2026-07-28 schema's JSONObject + ClientRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ClientRequest) => { sdk = spec; spec = sdk; }, - ServerRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ServerRequest) => { - // @ts-expect-error 2025-11-25 vs 2026-07-28 typing of CreateMessageRequest params; see the CreateMessageRequestParams check above + ServerRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ServerRequest) => { sdk = spec; - // @ts-expect-error 2025-11-25 vs 2026-07-28 typing of CreateMessageRequest params; see the CreateMessageRequestParams check above spec = sdk; }, LoggingMessageNotification: (sdk: WithJSONRPC, spec: SpecTypes.LoggingMessageNotification) => { sdk = spec; spec = sdk; }, - ServerNotification: (sdk: WithJSONRPC, spec: SpecTypes.ServerNotification) => { + ServerNotification: (sdk: WithJSONRPC, spec: SpecTypes.ServerNotification) => { sdk = spec; spec = sdk; }, @@ -662,14 +677,6 @@ type AssertExactKeys< /** Constraint: T must resolve to `true`. */ type Assert = T; -/** - * Same as {@link AssertExactKeys}, but tolerates the SDK's `resultType` key on - * result shapes: the SDK follows the 2026-07-28 schema's optional `resultType` - * passthrough (absent means "complete"), which is not in released 2025-11-25. - * Every other key still has to match exactly. - */ -type AssertExactKeysWithResultType = AssertExactKeys; - /* * Excluded from key-level assertions (21 entries): * @@ -710,38 +717,34 @@ type _K_ElicitRequestURLParams = Assert>; type _K_BaseMetadata = Assert>; type _K_Implementation = Assert>; -type _K_PaginatedResult = Assert>; -type _K_ListRootsResult = Assert>; +type _K_PaginatedResult = Assert>; +type _K_ListRootsResult = Assert>; type _K_Root = Assert>; -type _K_ElicitResult = Assert>; -type _K_CompleteResult = Assert>; +type _K_ElicitResult = Assert>; +type _K_CompleteResult = Assert>; type _K_Request = Assert>; -type _K_Result = Assert>; +type _K_Result = Assert>; type _K_JSONRPCRequest = Assert>; type _K_JSONRPCNotification = Assert>; -type _K_EmptyResult = Assert>; +type _K_EmptyResult = Assert>; type _K_Notification = Assert>; type _K_ResourceTemplateReference = Assert>; -// @ts-expect-error Genuine mismatch: SDK PromptReference is missing 'title' from spec -type _K_PromptReference = Assert>; +type _K_PromptReference = Assert>; type _K_ToolAnnotations = Assert>; type _K_Tool = Assert>; -type _K_ListToolsResult = Assert>; -type _K_CallToolResult = Assert>; -type _K_ListResourcesResult = Assert>; -type _K_ListResourceTemplatesResult = Assert< - AssertExactKeysWithResultType ->; -type _K_ReadResourceResult = Assert>; +type _K_ListToolsResult = Assert>; +type _K_CallToolResult = Assert>; +type _K_ListResourcesResult = Assert>; +type _K_ListResourceTemplatesResult = Assert>; +type _K_ReadResourceResult = Assert>; type _K_ResourceContents = Assert>; type _K_TextResourceContents = Assert>; type _K_BlobResourceContents = Assert>; type _K_Resource = Assert>; -// @ts-expect-error Genuine mismatch: SDK PromptArgument is missing 'title' from spec -type _K_PromptArgument = Assert>; +type _K_PromptArgument = Assert>; type _K_Prompt = Assert>; -type _K_ListPromptsResult = Assert>; -type _K_GetPromptResult = Assert>; +type _K_ListPromptsResult = Assert>; +type _K_GetPromptResult = Assert>; type _K_TextContent = Assert>; type _K_ImageContent = Assert>; type _K_AudioContent = Assert>; @@ -764,11 +767,9 @@ type _K_TitledMultiSelectEnumSchema = Assert>; type _K_JSONRPCErrorResponse = Assert>; type _K_JSONRPCResultResponse = Assert>; -type _K_InitializeResult = Assert>; -// @ts-expect-error SDK follows the 2026-07-28 schema's `extensions` capability key; not in released 2025-11-25 -type _K_ClientCapabilities = Assert>; -// @ts-expect-error SDK follows the 2026-07-28 schema's `extensions` capability key; not in released 2025-11-25 -type _K_ServerCapabilities = Assert>; +type _K_InitializeResult = Assert>; +type _K_ClientCapabilities = Assert>; +type _K_ServerCapabilities = Assert>; type _K_SamplingMessage = Assert>; type _K_Icon = Assert>; type _K_Icons = Assert>; @@ -783,11 +784,11 @@ type _K_TaskMetadata = Assert>; type _K_TaskAugmentedRequestParams = Assert>; type _K_Task = Assert>; -type _K_CreateTaskResult = Assert>; -type _K_GetTaskResult = Assert>; -type _K_GetTaskPayloadResult = Assert>; -type _K_ListTasksResult = Assert>; -type _K_CancelTaskResult = Assert>; +type _K_CreateTaskResult = Assert>; +type _K_GetTaskResult = Assert>; +type _K_GetTaskPayloadResult = Assert>; +type _K_ListTasksResult = Assert>; +type _K_CancelTaskResult = Assert>; type _K_TaskStatusNotificationParams = Assert< AssertExactKeys >; @@ -855,7 +856,7 @@ type _K_CancelTaskRequest = Assert>; +type _K_CreateMessageResult = Assert>; type _K_ResourceTemplate = Assert>; // Types excluded from the key-parity completeness guard: union types and primitive aliases diff --git a/packages/core/test/spec.types.2026-07-28.test.ts b/packages/core/test/spec.types.2026-07-28.test.ts index 064221963a..5bf80604c6 100644 --- a/packages/core/test/spec.types.2026-07-28.test.ts +++ b/packages/core/test/spec.types.2026-07-28.test.ts @@ -1,15 +1,20 @@ /** - * Compares the SDK's types against the upcoming 2026-07-28 schema (spec.types.2026-07-28.ts). - * The frozen-release comparison lives in spec.types.2025-11-25.test.ts. + * Per-revision parity: the 2026-era WIRE artifacts against the 2026-07-28 + * anchor (spec.types.2026-07-28.ts). The frozen-release comparison lives in + * spec.types.2025-11-25.test.ts. * - * The SDK does not implement the 2026-07-28 surface yet: every 2026-07-28 type whose shape the SDK - * does not (yet) match is listed in MISSING_SDK_TYPES_2026_07_28 below. Removing a name from - * that list forces a real mutual-assignability check to be added to sdkTypeChecks (the - * completeness tests below fail otherwise) — implementation work burns the list down. + * Q1 increment 2 retired the old 67-name burn-down list (whose "permanent + * stratum" could never burn under a single shared schema set): the SDK now + * models era-specific wire shapes in `wire/rev2026-07-28/`, and everything + * that module models is compared here EXACTLY — wire-true request views + * (envelope-required `_meta`), resultType-required result wrappers, the + * forked Tool/SamplingMessage payloads, response envelopes, and discover. * - * Unlike MISSING_SDK_TYPES in the 2025-11-25 comparison, names in this list may well - * exist in the SDK (e.g. RequestParams) — they are listed because the 2026-07-28 revision changed - * their shape, not necessarily because the SDK lacks them. + * What remains unmodeled lives in FEATURE_OWNED_PENDING_2026 below: every + * entry is OWNED by a named feature issue and is stale-checked — adding a + * check for a pending name forces the entry's removal, and the completeness + * tests fail on any spec type that is neither checked nor owned. There is no + * permanent stratum: when the owning features land, the list reaches zero. */ import fs from 'node:fs'; import path from 'node:path'; @@ -21,6 +26,8 @@ import { } from '../src/types/spec.types.2026-07-28.js'; import type * as SpecTypes from '../src/types/spec.types.2026-07-28.js'; import type * as SDKTypes from '../src/types/index.js'; +import type * as Wire2026 from '../src/wire/rev2026-07-28/schemas.js'; +import type * as z4 from 'zod/v4'; import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, @@ -37,6 +44,40 @@ type WithJSONRPC = T & { jsonrpc: '2.0' }; // Adds the `jsonrpc` and `id` properties to a type, to match the on-wire format of requests. type WithJSONRPCRequest = T & { jsonrpc: '2.0'; id: SDKTypes.RequestId }; +/* The 2026-era wire artifacts under comparison (inferred from the era module's + * Zod schemas — the same objects the codec parses with). */ +type WResult = z4.infer; +type WResultType = z4.infer; +type WPaginatedResult = z4.infer; +type WCacheableResult = z4.infer; +type WCallToolResult = z4.infer; +type WCompleteResult = z4.infer; +type WGetPromptResult = z4.infer; +type WListPromptsResult = z4.infer; +type WListResourceTemplatesResult = z4.infer; +type WListResourcesResult = z4.infer; +type WListToolsResult = z4.infer; +type WReadResourceResult = z4.infer; +type WDiscoverResult = z4.infer; +type WTool = z4.infer; +type WSamplingMessage = z4.infer; +type WJSONRPCResultResponse = z4.infer; +type WCompleteRequest = z4.infer; +type WListPromptsRequest = z4.infer; +type WListResourceTemplatesRequest = z4.infer; +type WListResourcesRequest = z4.infer; +type WListToolsRequest = z4.infer; +type WReadResourceRequest = z4.infer; +type WDiscoverRequest = z4.infer; +// Param/base shapes derived from the request views (no second source of truth): +type WRequestParams = NonNullable; +type WPaginatedRequestParams = WListToolsRequest['params']; +type WResourceRequestParams = WReadResourceRequest['params']; +type WCompleteRequestParams = WCompleteRequest['params']; +// PaginatedRequest in the anchor keeps `method: string` (it is the base, not +// a concrete method) — composed from the derived params shape. +type WPaginatedRequest = WithJSONRPCRequest<{ method: string; params: WPaginatedRequestParams }>; + const sdkTypeChecks = { JSONValue: (sdk: SDKTypes.JSONValue, spec: SpecTypes.JSONValue) => { sdk = spec; @@ -358,6 +399,13 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, + ElicitationCompleteNotificationParams: ( + sdk: SDKTypes.ElicitationCompleteNotificationParams, + spec: SpecTypes.ElicitationCompleteNotificationParams + ) => { + sdk = spec; + spec = sdk; + }, ElicitationCompleteNotification: ( sdk: WithJSONRPC, spec: SpecTypes.ElicitationCompleteNotification @@ -371,124 +419,245 @@ const sdkTypeChecks = { } }; +/* 2026-era wire parity checks (Q1 increment 2) — appended to sdkTypeChecks. */ +const wireParityChecks = { + Result: (sdk: WResult, spec: SpecTypes.Result) => { + sdk = spec; + spec = sdk; + }, + ResultType: (sdk: WResultType, spec: SpecTypes.ResultType) => { + sdk = spec; + spec = sdk; + }, + EmptyResult: (sdk: WResult, spec: SpecTypes.EmptyResult) => { + sdk = spec; + spec = sdk; + }, + ClientResult: (sdk: WResult, spec: SpecTypes.ClientResult) => { + sdk = spec; + spec = sdk; + }, + PaginatedResult: (sdk: WPaginatedResult, spec: SpecTypes.PaginatedResult) => { + sdk = spec; + spec = sdk; + }, + CacheableResult: (sdk: WCacheableResult, spec: SpecTypes.CacheableResult) => { + sdk = spec; + spec = sdk; + }, + CallToolResult: (sdk: WCallToolResult, spec: SpecTypes.CallToolResult) => { + sdk = spec; + spec = sdk; + }, + CompleteResult: (sdk: WCompleteResult, spec: SpecTypes.CompleteResult) => { + sdk = spec; + spec = sdk; + }, + GetPromptResult: (sdk: WGetPromptResult, spec: SpecTypes.GetPromptResult) => { + sdk = spec; + spec = sdk; + }, + ListPromptsResult: (sdk: WListPromptsResult, spec: SpecTypes.ListPromptsResult) => { + sdk = spec; + spec = sdk; + }, + ListResourceTemplatesResult: (sdk: WListResourceTemplatesResult, spec: SpecTypes.ListResourceTemplatesResult) => { + sdk = spec; + spec = sdk; + }, + ListResourcesResult: (sdk: WListResourcesResult, spec: SpecTypes.ListResourcesResult) => { + sdk = spec; + spec = sdk; + }, + ListToolsResult: (sdk: WListToolsResult, spec: SpecTypes.ListToolsResult) => { + sdk = spec; + spec = sdk; + }, + ReadResourceResult: (sdk: WReadResourceResult, spec: SpecTypes.ReadResourceResult) => { + sdk = spec; + spec = sdk; + }, + DiscoverResult: (sdk: WDiscoverResult, spec: SpecTypes.DiscoverResult) => { + sdk = spec; + spec = sdk; + }, + DiscoverRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.DiscoverRequest) => { + sdk = spec; + spec = sdk; + }, + Tool: (sdk: WTool, spec: SpecTypes.Tool) => { + sdk = spec; + spec = sdk; + }, + SamplingMessage: (sdk: WSamplingMessage, spec: SpecTypes.SamplingMessage) => { + sdk = spec; + spec = sdk; + }, + SamplingMessageContentBlock: ( + sdk: z4.infer, + spec: SpecTypes.SamplingMessageContentBlock + ) => { + sdk = spec; + spec = sdk; + }, + ToolResultContent: (sdk: z4.infer, spec: SpecTypes.ToolResultContent) => { + sdk = spec; + spec = sdk; + }, + Notification: (sdk: SDKTypes.Notification, spec: SpecTypes.Notification) => { + sdk = spec; + spec = sdk; + }, + JSONRPCResultResponse: (sdk: WJSONRPCResultResponse, spec: SpecTypes.JSONRPCResultResponse) => { + sdk = spec; + spec = sdk; + }, + JSONRPCResponse: (sdk: WJSONRPCResultResponse | SDKTypes.JSONRPCErrorResponse, spec: SpecTypes.JSONRPCResponse) => { + sdk = spec; + spec = sdk; + }, + JSONRPCMessage: ( + sdk: SDKTypes.JSONRPCRequest | WithJSONRPC | WJSONRPCResultResponse | SDKTypes.JSONRPCErrorResponse, + spec: SpecTypes.JSONRPCMessage + ) => { + sdk = spec; + spec = sdk; + }, + CompleteResultResponse: (sdk: z4.infer, spec: SpecTypes.CompleteResultResponse) => { + sdk = spec; + spec = sdk; + }, + ListPromptsResultResponse: ( + sdk: z4.infer, + spec: SpecTypes.ListPromptsResultResponse + ) => { + sdk = spec; + spec = sdk; + }, + ListResourceTemplatesResultResponse: ( + sdk: z4.infer, + spec: SpecTypes.ListResourceTemplatesResultResponse + ) => { + sdk = spec; + spec = sdk; + }, + ListResourcesResultResponse: ( + sdk: z4.infer, + spec: SpecTypes.ListResourcesResultResponse + ) => { + sdk = spec; + spec = sdk; + }, + ListToolsResultResponse: (sdk: z4.infer, spec: SpecTypes.ListToolsResultResponse) => { + sdk = spec; + spec = sdk; + }, + DiscoverResultResponse: (sdk: z4.infer, spec: SpecTypes.DiscoverResultResponse) => { + sdk = spec; + spec = sdk; + }, + RequestParams: (sdk: WRequestParams, spec: SpecTypes.RequestParams) => { + sdk = spec; + spec = sdk; + }, + PaginatedRequestParams: (sdk: WPaginatedRequestParams, spec: SpecTypes.PaginatedRequestParams) => { + sdk = spec; + spec = sdk; + }, + ResourceRequestParams: (sdk: WResourceRequestParams, spec: SpecTypes.ResourceRequestParams) => { + sdk = spec; + spec = sdk; + }, + CompleteRequestParams: (sdk: WCompleteRequestParams, spec: SpecTypes.CompleteRequestParams) => { + sdk = spec; + spec = sdk; + }, + PaginatedRequest: (sdk: WPaginatedRequest, spec: SpecTypes.PaginatedRequest) => { + sdk = spec; + spec = sdk; + }, + CompleteRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CompleteRequest) => { + sdk = spec; + spec = sdk; + }, + ListPromptsRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListPromptsRequest) => { + sdk = spec; + spec = sdk; + }, + ListResourceTemplatesRequest: ( + sdk: WithJSONRPCRequest, + spec: SpecTypes.ListResourceTemplatesRequest + ) => { + sdk = spec; + spec = sdk; + }, + ListResourcesRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListResourcesRequest) => { + sdk = spec; + spec = sdk; + }, + ListToolsRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListToolsRequest) => { + sdk = spec; + spec = sdk; + } +}; + +const allTypeChecks = { ...sdkTypeChecks, ...wireParityChecks }; + // Generated from the 2026-07-28 schema by `pnpm run fetch:spec-types 2026-07-28 `. const SPEC_TYPES_FILE = path.resolve(__dirname, '../src/types/spec.types.2026-07-28.ts'); /** - * 2026-07-28 spec types the SDK does not match yet. Spec-implementation work for the - * 2026-07-28 release removes entries from this list as the SDK adopts each shape. + * Spec types the 2026-era wire module does not model yet — every entry is + * OWNED by a named feature issue (no permanent stratum; the list reaches + * zero as the owners land). Adding a parity check for one of these names + * forces the entry's removal (stale-check below). */ -const MISSING_SDK_TYPES_2026_07_28 = [ +const FEATURE_OWNED_PENDING_2026: Record = { // Inlined in the SDK (same as the 2025-11-25 comparison): - 'Error', // The inner error object of a JSONRPCError - - // SEP-2575 per-request envelope: 2026-07-28 requests REQUIRE a `_meta` envelope - // (`io.modelcontextprotocol/protocolVersion`, clientInfo, clientCapabilities). The - // envelope itself is modeled by RequestMetaEnvelope (see sdkTypeChecks above); the - // request shapes below stay here because the SDK wire schemas deliberately keep - // `_meta` lenient — the same schemas parse pre-2026 requests (no envelope) and 2026 - // requests, with envelope requiredness enforced per request at dispatch. They burn - // only if the SDK ever models era-specific request types. - 'RequestParams', - 'PaginatedRequestParams', - 'ResourceRequestParams', - 'CallToolRequestParams', - 'CompleteRequestParams', - 'GetPromptRequestParams', - 'ReadResourceRequestParams', - 'CreateMessageRequestParams', - 'PaginatedRequest', - 'CallToolRequest', - 'CompleteRequest', - 'GetPromptRequest', - 'ListPromptsRequest', - 'ListResourceTemplatesRequest', - 'ListResourcesRequest', - 'ListRootsRequest', - 'ListToolsRequest', - 'ReadResourceRequest', - 'CreateMessageRequest', - 'ClientRequest', - - // SEP-2322 (MRTR) → PR for MRTR: 2026-07-28 results carry a required `resultType` - // discriminator. The SDK base result schema carries `resultType` as an optional - // passthrough only (absent means "complete"); per-result modeling lands with MRTR. - 'Result', - 'EmptyResult', - 'PaginatedResult', - 'CallToolResult', - 'CompleteResult', - 'ElicitResult', - 'GetPromptResult', - 'ListPromptsResult', - 'ListResourceTemplatesResult', - 'ListResourcesResult', - 'ListRootsResult', - 'ListToolsResult', - 'ReadResourceResult', - 'CreateMessageResult', - 'ClientResult', - 'ServerResult', - 'ResultType', - - // SEP-2549 cacheable results: `ttlMs`/`cacheScope` caching hints on the list/read - // result shapes → PR for SEP-2549: - 'CacheableResult', + Error: 'the inner error object of a JSONRPCError is inlined in the SDK', - // Response envelopes embedding the changed Result shape → PR for MRTR: - 'JSONRPCResultResponse', - 'JSONRPCResponse', - 'JSONRPCMessage', - 'CallToolResultResponse', - 'CompleteResultResponse', - 'GetPromptResultResponse', - 'ListPromptsResultResponse', - 'ListResourceTemplatesResultResponse', - 'ListResourcesResultResponse', - 'ListToolsResultResponse', - 'ReadResourceResultResponse', + // M4.1 MRTR (#13): the in-band input-request surface and the demoted + // sampling/elicitation/roots shapes (wire requests in 2025, in-band + // InputRequest payloads in 2026 — the SDK models them when the + // multi-round-trip driver lands): + InputRequest: 'M4.1 MRTR (#13)', + InputRequests: 'M4.1 MRTR (#13)', + InputRequiredResult: 'M4.1 MRTR (#13)', + InputResponse: 'M4.1 MRTR (#13)', + InputResponseRequestParams: 'M4.1 MRTR (#13)', + InputResponses: 'M4.1 MRTR (#13)', + CreateMessageRequest: 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026', + CreateMessageRequestParams: 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026', + CreateMessageResult: 'M4.1 MRTR (#13) — in-band response shape', + ElicitResult: 'M4.1 MRTR (#13) — in-band response shape', + ListRootsRequest: 'M4.1 MRTR (#13) — demoted to an in-band payload in 2026', + ListRootsResult: 'M4.1 MRTR (#13) — in-band response shape', + ServerResult: 'M4.1 MRTR (#13) — the union gains InputRequiredResult', + CallToolRequestParams: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', + CallToolRequest: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', + GetPromptRequestParams: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', + GetPromptRequest: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', + ReadResourceRequestParams: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', + ReadResourceRequest: 'M4.1 MRTR (#13) — params extend InputResponseRequestParams (the retry channel)', + CallToolResultResponse: 'M4.1 MRTR (#13) — the result union gains InputRequiredResult', + GetPromptResultResponse: 'M4.1 MRTR (#13) — the result union gains InputRequiredResult', + ReadResourceResultResponse: 'M4.1 MRTR (#13) — the result union gains InputRequiredResult', - // SEP-2575 sessionless discovery: the SDK ships the wire shapes - // (DiscoverRequestSchema / DiscoverResultSchema), but the 2026-07-28 shapes embed the - // required `_meta` envelope (request) and required `resultType` (result → MRTR PR), - // so they do not match yet; DiscoverResultResponse is a response wrapper (→ MRTR PR): - 'DiscoverRequest', - 'DiscoverResult', - 'DiscoverResultResponse', + // M6.1 subscriptions/listen (#14): + SubscriptionFilter: 'M6.1 subscriptions/listen (#14)', + SubscriptionsAcknowledgedNotification: 'M6.1 subscriptions/listen (#14)', + SubscriptionsAcknowledgedNotificationParams: 'M6.1 subscriptions/listen (#14)', + SubscriptionsListenRequest: 'M6.1 subscriptions/listen (#14)', + SubscriptionsListenRequestParams: 'M6.1 subscriptions/listen (#14)', + ClientRequest: 'M6.1 subscriptions/listen (#14) — the union gains SubscriptionsListenRequest', + ServerNotification: 'M6.1 subscriptions/listen (#14) — the union gains the acknowledged notification', - // SEP-2567 input requests/responses (new surface) → PR for MRTR: - 'InputRequest', - 'InputRequests', - 'InputRequiredResult', - 'InputResponse', - 'InputResponseRequestParams', - 'InputResponses', - - // 2026-07-28 subscriptions surface (new) → PR for subscriptions/listen: - 'SubscriptionFilter', - 'SubscriptionsAcknowledgedNotification', - 'SubscriptionsAcknowledgedNotificationParams', - 'SubscriptionsListenRequest', - 'SubscriptionsListenRequestParams', - - // New typed protocol errors: the SDK ships -32003/-32004 as ProtocolErrorCode - // entries plus the UnsupportedProtocolVersionError class (errors.ts); the spec's - // per-code error *response envelope* interfaces are not modeled as wire types: - 'MissingRequiredClientCapabilityError', - 'UnsupportedProtocolVersionError', + // M1.2 validation ladder (#8): the per-code error response envelopes: + MissingRequiredClientCapabilityError: 'M1.2 validation ladder (#8)', + UnsupportedProtocolVersionError: 'M1.2 validation ladder (#8)' +}; - // Other shapes changed in the 2026-07-28 schema: sampling content changes (SamplingMessage, - // SamplingMessageContentBlock, ToolResultContent) → backchannel PR; open tool - // input/output schema typing (Tool); loosened Notification.params (Notification); - // server notification union, which gains the subscriptions ack (ServerNotification → - // PR for subscriptions/listen): - 'SamplingMessage', - 'SamplingMessageContentBlock', - 'ToolResultContent', - 'Tool', - 'Notification', - 'ServerNotification' -]; +const MISSING_SDK_TYPES_2026_07_28 = Object.keys(FEATURE_OWNED_PENDING_2026); function extractExportedTypes(source: string): string[] { const matches = [...source.matchAll(/export\s+(?:interface|class|type)\s+(\w+)\b/g)]; @@ -518,7 +687,7 @@ describe('Spec Types (2026-07-28)', () => { expect(specTypes).toContain('DiscoverRequest'); expect(specTypes).toContain('InputRequiredResult'); expect(specTypes).toContain('SubscriptionsListenRequest'); - expect(specTypes).toHaveLength(150); + expect(specTypes).toHaveLength(151); }); it('should only allowlist types that exist in the 2026-07-28 schema', () => { @@ -531,7 +700,7 @@ describe('Spec Types (2026-07-28)', () => { const missingTests = []; for (const typeName of typesToCheck) { - if (!sdkTypeChecks[typeName as keyof typeof sdkTypeChecks]) { + if (!allTypeChecks[typeName as keyof typeof allTypeChecks]) { missingTests.push(typeName); } } @@ -539,12 +708,15 @@ describe('Spec Types (2026-07-28)', () => { expect(missingTests).toHaveLength(0); }); - describe('Missing SDK Types', () => { - it.each(MISSING_SDK_TYPES_2026_07_28)( - '%s should not be present in MISSING_SDK_TYPES_2026_07_28 if it has a compatibility test', - type => { - expect(sdkTypeChecks[type as keyof typeof sdkTypeChecks]).toBeUndefined(); + describe('Feature-owned pending entries', () => { + it.each(MISSING_SDK_TYPES_2026_07_28)('%s must not be pending once it has a parity check (stale-check)', type => { + expect(allTypeChecks[type as keyof typeof allTypeChecks]).toBeUndefined(); + }); + + it('every pending entry names its owner', () => { + for (const [name, owner] of Object.entries(FEATURE_OWNED_PENDING_2026)) { + expect(owner.length, name).toBeGreaterThan(0); } - ); + }); }); }); diff --git a/packages/core/test/types.test.ts b/packages/core/test/types.test.ts index a92615bceb..70b0b02a82 100644 --- a/packages/core/test/types.test.ts +++ b/packages/core/test/types.test.ts @@ -18,7 +18,6 @@ import { LOG_LEVEL_META_KEY, PromptMessageSchema, PROTOCOL_VERSION_META_KEY, - RequestMetaEnvelopeSchema, ResourceLinkSchema, ResultSchema, SamplingMessageSchema, @@ -28,6 +27,12 @@ import { ToolSchema, ToolUseContentSchema } from '../src/types/index.js'; +// Wire-era modules (Q1 increment 2): the per-request envelope lives in the +// 2026-era schemas; the era-faithful 2025 role unions (incl. tasks) live in +// the 2025-era schemas. +import { getRequestSchema } from '../src/wire/rev2025-11-25/registry.js'; +import { ClientRequestSchema as Wire2025ClientRequestSchema } from '../src/wire/rev2025-11-25/schemas.js'; +import { RequestMetaEnvelopeSchema } from '../src/wire/rev2026-07-28/schemas.js'; describe('Types', () => { test('should have correct latest protocol version', () => { @@ -291,10 +296,13 @@ describe('Types', () => { } }); - test('should validate empty content array with default', () => { - const toolResult = {}; - - const result = CallToolResultSchema.safeParse(toolResult); + test('requires content: the empty-object result no longer parses (deliberate flip)', () => { + // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): content.default([]) + // was removed from the wire schema (the T6 silent-empty-success + // masking root). Content is spec-required in every revision. + // Changeset: codec-split-wire-break. + expect(CallToolResultSchema.safeParse({}).success).toBe(false); + const result = CallToolResultSchema.safeParse({ content: [] }); expect(result.success).toBe(true); if (result.success) { expect(result.data.content).toEqual([]); @@ -567,6 +575,9 @@ describe('Types', () => { const toolResult = { type: 'tool_result', toolUseId: 'call_123', + // content is spec-required (the wire default([]) was removed — + // Q1 increment 2, ledgered; changeset: codec-split-wire-break). + content: [], structuredContent: { temperature: 72, condition: 'sunny' } }; @@ -583,6 +594,7 @@ describe('Types', () => { const toolResult = { type: 'tool_result', toolUseId: 'call_456', + content: [], structuredContent: { error: 'API_ERROR', message: 'Service unavailable' }, isError: true }; @@ -1025,9 +1037,15 @@ describe('Types', () => { }); describe('2025-11-25 task wire interop (task feature removed; wire types remain)', () => { - test('tasks/get parses through the client request union', () => { - const result = ClientRequestSchema.safeParse({ method: 'tasks/get', params: { taskId: 'task-123' } }); + test('tasks/get parses through the 2025-era wire request union and registry', () => { + // The task wire surface moved into the 2025-era codec module (Q1 + // increment 2): interop with task-capable 2025 peers is served by the + // era registry, and the NEUTRAL ClientRequestSchema no longer carries + // task vocabulary (deletions are physical on the 2026 era). + const result = Wire2025ClientRequestSchema.safeParse({ method: 'tasks/get', params: { taskId: 'task-123' } }); expect(result.success).toBe(true); + expect(getRequestSchema('tasks/get')).toBeDefined(); + expect(ClientRequestSchema.options.some(option => (option.shape.method.value as string) === 'tasks/get')).toBe(false); }); test('task-augmented tools/call params parse and retain the task field', () => { @@ -1148,26 +1166,25 @@ describe('2026-07-28 wire shapes', () => { }); }); - describe('Result resultType passthrough', () => { - test('accepts results with and without resultType (absent means "complete")', () => { + describe('Result resultType (cut from the neutral schemas — Q1 increment 2, ledgered)', () => { + test('the base ResultSchema no longer declares resultType; the key is loose passthrough only', () => { + // BEHAVIOR MIGRATION: the optional resultType member — the + // masking surface that let 2026 vocabulary through every + // legacy-leg parse — is gone. The wire member lives only in the + // 2026-era codec module. A foreign resultType still transits the + // loose base parse as an UNDECLARED sibling (it can no longer + // type-check, and the protocol path strips/consumes it per era). const withIt = ResultSchema.safeParse({ resultType: 'complete' }); expect(withIt.success).toBe(true); - if (withIt.success) { - expect(withIt.data.resultType).toBe('complete'); - } - const withoutIt = ResultSchema.safeParse({}); - expect(withoutIt.success).toBe(true); - if (withoutIt.success) { - expect(withoutIt.data.resultType).toBeUndefined(); - } - }); - - test('rejects a non-string resultType', () => { - expect(ResultSchema.safeParse({ resultType: 42 }).success).toBe(false); + // Non-string values are no longer schema-rejected here (the + // member is undeclared): era handling owns the raw value. + expect(ResultSchema.safeParse({ resultType: 42 }).success).toBe(true); + expect(Object.keys(ResultSchema.shape)).toEqual(['_meta']); }); - test('EmptyResult accepts resultType but still rejects unknown keys', () => { - expect(EmptyResultSchema.safeParse({ resultType: 'complete' }).success).toBe(true); + test('EmptyResult rejects resultType like any unknown key (deliberate flip)', () => { + // Changeset: codec-split-wire-break. + expect(EmptyResultSchema.safeParse({ resultType: 'complete' }).success).toBe(false); expect(EmptyResultSchema.safeParse({ unexpected: true }).success).toBe(false); }); }); diff --git a/packages/core/test/types/crossBundleErrorRecognition.test.ts b/packages/core/test/types/crossBundleErrorRecognition.test.ts new file mode 100644 index 0000000000..35f69acbae --- /dev/null +++ b/packages/core/test/types/crossBundleErrorRecognition.test.ts @@ -0,0 +1,131 @@ +/** + * Cross-bundle typed-error recognition guard. + * + * The core package is bundled separately into the client and server dists, so + * a typed error class constructed inside one bundle is NOT `instanceof` the + * "same" class imported from another bundle. The recognition contract is + * therefore: typed protocol errors are materialized from the wire shape — + * numeric `code` plus structurally parsed `error.data` — and consumers (and + * the SDK itself) must never rely on `instanceof` across the package boundary. + * + * These tests pin that contract from both directions: + * - recognition succeeds for plain wire values and for foreign-prototype + * instances (simulating an error object created by another bundled copy of + * core), and + * - recognition is purely structural — malformed `data` falls back to the + * generic class rather than guessing or throwing. + */ +import { describe, expect, test } from 'vitest'; + +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol } from '../../src/shared/protocol.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; +import type { JSONRPCRequest } from '../../src/types/index.js'; +import { ProtocolError, ProtocolErrorCode, UnsupportedProtocolVersionError, UrlElicitationRequiredError } from '../../src/types/index.js'; + +class TestProtocol extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +/** + * A structural twin of `UnsupportedProtocolVersionError` with its own + * prototype chain — what an error created by a second bundled copy of core + * looks like to this copy: same name, same fields, different identity. + */ +class ForeignUnsupportedProtocolVersionError extends Error { + readonly code = -32_004; + readonly data = { supported: ['2025-11-25'], requested: '2099-01-01' }; + constructor() { + super('Unsupported protocol version: 2099-01-01'); + this.name = 'UnsupportedProtocolVersionError'; + } +} + +describe('cross-bundle typed-error recognition (data parse, never instanceof)', () => { + test('a -32004 error received over the wire materializes the typed class from code + data', async () => { + // Full dispatch round trip: the peer answers with a plain JSON error + // body — exactly what crosses a transport (and a bundle) boundary. + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = message => { + const request = message as JSONRPCRequest; + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + error: { + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: ['2025-11-25', '2025-06-18'], requested: '2099-01-01' } + } + }); + }; + await serverTx.start(); + + const protocol = new TestProtocol(); + await protocol.connect(clientTx); + + const rejection = await protocol.request({ method: 'ping' }).catch((error: unknown) => error); + + // The receiving side gets the typed class, materialized purely from + // the wire shape (numeric code + structurally valid data). + expect(rejection).toBeInstanceOf(UnsupportedProtocolVersionError); + const typed = rejection as UnsupportedProtocolVersionError; + expect(typed.code).toBe(ProtocolErrorCode.UnsupportedProtocolVersion); + expect(typed.supported).toEqual(['2025-11-25', '2025-06-18']); + expect(typed.requested).toBe('2099-01-01'); + + await protocol.close(); + }); + + test('recognition works for a foreign-prototype instance via its code/data, not its identity', () => { + const foreign = new ForeignUnsupportedProtocolVersionError(); + + // The foreign instance is NOT instanceof this bundle's classes — the + // exact situation `instanceof` checks silently get wrong. + expect(foreign instanceof UnsupportedProtocolVersionError).toBe(false); + expect(foreign instanceof ProtocolError).toBe(false); + + // Recognition through the wire shape still succeeds. + const recognized = ProtocolError.fromError(foreign.code, foreign.message, foreign.data); + expect(recognized).toBeInstanceOf(UnsupportedProtocolVersionError); + expect((recognized as UnsupportedProtocolVersionError).supported).toEqual(['2025-11-25']); + expect((recognized as UnsupportedProtocolVersionError).requested).toBe('2099-01-01'); + }); + + test('recognition survives JSON serialization (no prototype information required)', () => { + // Serialize a locally constructed typed error down to its wire shape + // and re-recognize it — the round trip a bundled boundary forces. + const original = new UrlElicitationRequiredError([ + { mode: 'url', message: 'visit', url: 'https://example.com/elicit', elicitationId: 'e1' } + ]); + const wireShape = JSON.parse(JSON.stringify({ code: original.code, message: original.message, data: original.data })) as { + code: number; + message: string; + data: unknown; + }; + + const recognized = ProtocolError.fromError(wireShape.code, wireShape.message, wireShape.data); + expect(recognized).toBeInstanceOf(UrlElicitationRequiredError); + expect((recognized as UrlElicitationRequiredError).elicitations).toHaveLength(1); + expect((recognized as UrlElicitationRequiredError).elicitations[0]?.url).toBe('https://example.com/elicit'); + }); + + test('structurally invalid data falls back to the generic class — no guess, no throw', () => { + // -32004 with data that does not parse as UnsupportedProtocolVersionErrorData. + for (const data of [undefined, null, 'nope', { supported: 'not-an-array', requested: '2099-01-01' }, { wrong: 'shape' }]) { + const recognized = ProtocolError.fromError(-32_004, 'unsupported', data); + expect(recognized).toBeInstanceOf(ProtocolError); + expect(recognized).not.toBeInstanceOf(UnsupportedProtocolVersionError); + expect(recognized.code).toBe(-32_004); + } + + // -32042 with data missing the elicitations array. + const urlFallback = ProtocolError.fromError(-32_042, 'elicitation required', { other: true }); + expect(urlFallback).toBeInstanceOf(ProtocolError); + expect(urlFallback).not.toBeInstanceOf(UrlElicitationRequiredError); + }); +}); diff --git a/packages/core/test/types/discoverWiring.test.ts b/packages/core/test/types/discoverWiring.test.ts new file mode 100644 index 0000000000..b17b96101c --- /dev/null +++ b/packages/core/test/types/discoverWiring.test.ts @@ -0,0 +1,54 @@ +/** + * LC-02: `server/discover` wired into the typed request funnel — the wire + * shapes landed earlier but were deliberately union-excluded; this pins the + * widening into ClientRequestSchema / ServerResultSchema / the typed method + * maps. Per-era AVAILABILITY stays with the wire registries (one source of + * truth): the 2026-era registry serves the method, the 2025-era registry does + * not — there is no neutral runtime schema map to keep in sync. + */ +import { describe, expect, expectTypeOf, test } from 'vitest'; + +import { ClientRequestSchema, DiscoverResultSchema, ServerResultSchema } from '../../src/types/index.js'; +import type { DiscoverResult, RequestMethod, RequestTypeMap, ResultTypeMap } from '../../src/types/index.js'; +import { getRequestSchema, getResultSchema } from '../../src/wire/rev2025-11-25/registry.js'; +import { getRequestSchema2026, getResultSchema2026 } from '../../src/wire/rev2026-07-28/registry.js'; + +describe('server/discover typed-funnel wiring (LC-02)', () => { + test('ClientRequestSchema accepts a server/discover request', () => { + const parsed = ClientRequestSchema.safeParse({ method: 'server/discover' }); + expect(parsed.success).toBe(true); + }); + + test('ServerResultSchema accepts a discover result', () => { + const parsed = ServerResultSchema.safeParse({ + supportedVersions: ['2026-07-28'], + capabilities: {}, + serverInfo: { name: 's', version: '1' } + }); + expect(parsed.success).toBe(true); + }); + + test('the typed method maps carry server/discover', () => { + expectTypeOf<'server/discover'>().toExtend(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toMatchObjectType<{ method: 'server/discover' }>(); + }); + + test('per-era availability lives in the wire registries: 2026 serves it, 2025 does not', () => { + expect(getRequestSchema2026('server/discover')).toBeDefined(); + expect(getResultSchema2026('server/discover')).toBeDefined(); + expect(getRequestSchema('server/discover')).toBeUndefined(); + expect(getResultSchema('server/discover')).toBeUndefined(); + }); + + test('a discover result round-trips the schema with its advertisement intact', () => { + const result = DiscoverResultSchema.parse({ + supportedVersions: ['2026-07-28'], + capabilities: { tools: {} }, + serverInfo: { name: 'modern-server', version: '2.0.0' }, + instructions: 'use the tools' + }); + expect(result.supportedVersions).toEqual(['2026-07-28']); + expect(result.instructions).toBe('use the tools'); + }); +}); diff --git a/packages/core/test/types/errorSurfacePins.test.ts b/packages/core/test/types/errorSurfacePins.test.ts new file mode 100644 index 0000000000..46003004e4 --- /dev/null +++ b/packages/core/test/types/errorSurfacePins.test.ts @@ -0,0 +1,163 @@ +/** + * Behavior-surface pins: error codes, error classes, and version constants. + * + * Consumers match SDK errors by literal numeric code, `error.name`, and message + * text — not only by enum member or `instanceof` (which breaks across bundled + * package boundaries). These tests pin the literal values so that a renumber, + * rename, or membership change turns CI red instead of landing silently. A + * failing pin here means the change is deliberate: update the pin in the same + * change, together with a changeset and a migration-doc entry. + * + * See docs/behavior-surface-pins.md for the maintenance protocol. + */ +import { describe, expect, test } from 'vitest'; + +import { SdkError, SdkErrorCode, SdkHttpError } from '../../src/errors/sdkErrors.js'; +import { + DEFAULT_NEGOTIATED_PROTOCOL_VERSION, + INTERNAL_ERROR, + INVALID_PARAMS, + INVALID_REQUEST, + JSONRPC_VERSION, + LATEST_PROTOCOL_VERSION, + METHOD_NOT_FOUND, + PARSE_ERROR, + ProtocolError, + ProtocolErrorCode, + SUPPORTED_PROTOCOL_VERSIONS, + UnsupportedProtocolVersionError, + UrlElicitationRequiredError +} from '../../src/types/index.js'; +import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from '../../src/shared/stdio.js'; + +describe('ProtocolErrorCode', () => { + test('numeric values are frozen wire ABI', () => { + // Consumers map wire error codes by numeric value (value-to-label tables, + // duck-typed {code} checks across package boundaries), so the literal values + // are public ABI. Exact-equality on the whole table also locks membership in + // both directions: adding or removing a member is a deliberate act. + const members = Object.fromEntries(Object.entries(ProtocolErrorCode).filter(([key]) => Number.isNaN(Number(key)))); + expect(members).toEqual({ + ParseError: -32700, + InvalidRequest: -32600, + MethodNotFound: -32601, + InvalidParams: -32602, + InternalError: -32603, + ResourceNotFound: -32002, + MissingRequiredClientCapability: -32003, + UnsupportedProtocolVersion: -32004, + UrlElicitationRequired: -32042 + }); + }); + + test('bare JSON-RPC constant values are frozen', () => { + expect(PARSE_ERROR).toBe(-32700); + expect(INVALID_REQUEST).toBe(-32600); + expect(METHOD_NOT_FOUND).toBe(-32601); + expect(INVALID_PARAMS).toBe(-32602); + expect(INTERNAL_ERROR).toBe(-32603); + expect(JSONRPC_VERSION).toBe('2.0'); + }); +}); + +describe('SdkErrorCode', () => { + test('string values are frozen ABI', () => { + // SDK errors are local (never serialized to the wire) but consumers still + // branch on the literal string codes, so the values and the membership of + // the enum are pinned in both directions. + expect({ ...SdkErrorCode }).toEqual({ + NotConnected: 'NOT_CONNECTED', + AlreadyConnected: 'ALREADY_CONNECTED', + NotInitialized: 'NOT_INITIALIZED', + CapabilityNotSupported: 'CAPABILITY_NOT_SUPPORTED', + RequestTimeout: 'REQUEST_TIMEOUT', + ConnectionClosed: 'CONNECTION_CLOSED', + SendFailed: 'SEND_FAILED', + InvalidResult: 'INVALID_RESULT', + UnsupportedResultType: 'UNSUPPORTED_RESULT_TYPE', + MethodNotSupportedByProtocolVersion: 'METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION', + EraNegotiationFailed: 'ERA_NEGOTIATION_FAILED', + ClientHttpNotImplemented: 'CLIENT_HTTP_NOT_IMPLEMENTED', + ClientHttpAuthentication: 'CLIENT_HTTP_AUTHENTICATION', + ClientHttpForbidden: 'CLIENT_HTTP_FORBIDDEN', + ClientHttpUnexpectedContent: 'CLIENT_HTTP_UNEXPECTED_CONTENT', + ClientHttpFailedToOpenStream: 'CLIENT_HTTP_FAILED_TO_OPEN_STREAM', + ClientHttpFailedToTerminateSession: 'CLIENT_HTTP_FAILED_TO_TERMINATE_SESSION' + }); + }); +}); + +describe('ProtocolError', () => { + test('sets error.name, carries code/data, and leaves the message verbatim', () => { + // Consumers classify errors via err.name (instanceof breaks when core is + // bundled into both the client and server dists), and read .code/.data as + // a duck shape. The constructor must not decorate the message. + const error = new ProtocolError(ProtocolErrorCode.InvalidParams, 'oops', { extra: 1 }); + expect(error.name).toBe('ProtocolError'); + expect(error.code).toBe(-32602); + expect(error.data).toEqual({ extra: 1 }); + expect(error.message).toBe('oops'); + expect(error).toBeInstanceOf(Error); + }); + + test('fromError materializes typed errors from code + parsed data, not instanceof', () => { + // Cross-bundle recognition contract: typed error classes are reconstructed + // from the wire shape (numeric code + structurally valid data). The inputs + // here are plain values, exactly what arrives across a package boundary. + const urlError = ProtocolError.fromError(-32042, 'elicitation required', { + elicitations: [{ mode: 'url', message: 'visit', url: 'https://example.com', elicitationId: 'e1' }] + }); + expect(urlError).toBeInstanceOf(UrlElicitationRequiredError); + expect((urlError as UrlElicitationRequiredError).elicitations).toHaveLength(1); + + const versionError = ProtocolError.fromError(-32004, 'unsupported', { supported: ['2025-11-25'], requested: '1999-01-01' }); + expect(versionError).toBeInstanceOf(UnsupportedProtocolVersionError); + expect((versionError as UnsupportedProtocolVersionError).supported).toEqual(['2025-11-25']); + expect((versionError as UnsupportedProtocolVersionError).requested).toBe('1999-01-01'); + + // Malformed/missing data falls back to the generic class instead of throwing. + const generic = ProtocolError.fromError(-32004, 'unsupported', { wrong: 'shape' }); + expect(generic).toBeInstanceOf(ProtocolError); + expect(generic).not.toBeInstanceOf(UnsupportedProtocolVersionError); + }); +}); + +describe('SdkError', () => { + test('sets error.name and carries the string code', () => { + const error = new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out', { timeout: 60000 }); + expect(error.name).toBe('SdkError'); + expect(error.code).toBe('REQUEST_TIMEOUT'); + expect(error.data).toEqual({ timeout: 60000 }); + expect(error.message).toBe('Request timed out'); + }); + + test('SdkHttpError carries the HTTP status in data', () => { + const error = new SdkHttpError(SdkErrorCode.ClientHttpFailedToOpenStream, 'Failed to open SSE stream: Not Found', { + status: 404, + statusText: 'Not Found' + }); + expect(error.name).toBe('SdkHttpError'); + expect(error.code).toBe('CLIENT_HTTP_FAILED_TO_OPEN_STREAM'); + expect(error.data).toMatchObject({ status: 404 }); + }); +}); + +describe('protocol version constants', () => { + test('values and membership are frozen', () => { + // The supported list is pinned by exact value (not just membership) so a + // naive LATEST bump that silently drops a previous version goes red here. + expect(LATEST_PROTOCOL_VERSION).toBe('2025-11-25'); + expect(DEFAULT_NEGOTIATED_PROTOCOL_VERSION).toBe('2025-03-26'); + expect(SUPPORTED_PROTOCOL_VERSIONS).toEqual(['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']); + expect(SUPPORTED_PROTOCOL_VERSIONS).toContain(LATEST_PROTOCOL_VERSION); + expect(SUPPORTED_PROTOCOL_VERSIONS).toContain(DEFAULT_NEGOTIATED_PROTOCOL_VERSION); + }); +}); + +describe('stdio framing constants', () => { + test('the default read-buffer cap is 10 MiB', () => { + // Public export consumed by custom transport authors; raising or lowering + // the cap changes which deployed payloads parse, so the value is pinned. + expect(STDIO_DEFAULT_MAX_BUFFER_SIZE).toBe(10 * 1024 * 1024); + }); +}); 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); + }); +}); diff --git a/packages/core/test/types/registryPins.test.ts b/packages/core/test/types/registryPins.test.ts new file mode 100644 index 0000000000..73222b8eb5 --- /dev/null +++ b/packages/core/test/types/registryPins.test.ts @@ -0,0 +1,198 @@ +/** + * Registry byte-identity pre-pins for the wire-layer re-homing (Q1 increment 2). + * + * These tests pin the EXACT contents of the runtime method registries — + * method sets and per-method schema identity (by object reference) — so that + * relocating the registries behind the per-era codec interface is provably + * mechanical: the same schema objects must serve the same methods before and + * after the move. They are committed BEFORE the relocation lands (suite, then + * move — Q10-L2 ordering). + * + * The 2025-era registry is behavior-frozen: the request/notification maps + * carry the full deliberate 2025-11-25 wire vocabulary, including the task + * family (#2248 wire-interop restore). The RESULT map is the runtime/typed + * ALIGNED map (PR #2293 review fix): plain per-method schemas keyed by + * `RequestMethod` — no task-result union members and no `tasks/*` entries + * (task-method interop goes through the explicit-schema overload; see + * `test/shared/typedMapAlignment.test.ts` for the behavioral pins). Do not + * edit these pins to make a refactor pass; a pin change is a wire-behavior + * decision and needs a changeset + migration entry (Q10-L2). + */ +import { describe, expect, it } from 'vitest'; + +import { + CallToolRequestSchema, + CallToolResultSchema, + CancelledNotificationSchema, + CompleteRequestSchema, + CompleteResultSchema, + CreateMessageRequestSchema, + CreateMessageResultWithToolsSchema, + ElicitationCompleteNotificationSchema, + ElicitRequestSchema, + ElicitResultSchema, + EmptyResultSchema, + GetPromptRequestSchema, + GetPromptResultSchema, + InitializedNotificationSchema, + InitializeRequestSchema, + InitializeResultSchema, + ListPromptsRequestSchema, + ListPromptsResultSchema, + ListResourcesRequestSchema, + ListResourcesResultSchema, + ListResourceTemplatesRequestSchema, + ListResourceTemplatesResultSchema, + ListRootsRequestSchema, + ListRootsResultSchema, + ListToolsRequestSchema, + ListToolsResultSchema, + LoggingMessageNotificationSchema, + PingRequestSchema, + ProgressNotificationSchema, + PromptListChangedNotificationSchema, + ReadResourceRequestSchema, + ReadResourceResultSchema, + ResourceListChangedNotificationSchema, + ResourceUpdatedNotificationSchema, + RootsListChangedNotificationSchema, + SetLevelRequestSchema, + SubscribeRequestSchema, + ToolListChangedNotificationSchema, + UnsubscribeRequestSchema +} from '../../src/types/index.js'; +// Post-relocation home (Q1 increment-2 step 1): the pinned contents are +// unchanged — only the module housing the registries moved. +import { getNotificationSchema, getRequestSchema, getResultSchema } from '../../src/wire/rev2025-11-25/registry.js'; +// The 2025-only task wire vocabulary now lives in the era's schema module +// (Q1 increment-2 step 4); the schema OBJECTS serving the registry are the +// same — these pins still hold by reference. +import { + CancelTaskRequestSchema, + GetTaskPayloadRequestSchema, + GetTaskRequestSchema, + ListTasksRequestSchema, + TaskStatusNotificationSchema +} from '../../src/wire/rev2025-11-25/schemas.js'; + +/** The exact 2025-era request-method → schema map (today's wire surface, verbatim). */ +const EXPECTED_REQUEST_SCHEMAS = { + ping: PingRequestSchema, + initialize: InitializeRequestSchema, + 'completion/complete': CompleteRequestSchema, + 'logging/setLevel': SetLevelRequestSchema, + 'prompts/get': GetPromptRequestSchema, + 'prompts/list': ListPromptsRequestSchema, + 'resources/list': ListResourcesRequestSchema, + 'resources/templates/list': ListResourceTemplatesRequestSchema, + 'resources/read': ReadResourceRequestSchema, + 'resources/subscribe': SubscribeRequestSchema, + 'resources/unsubscribe': UnsubscribeRequestSchema, + 'tools/call': CallToolRequestSchema, + 'tools/list': ListToolsRequestSchema, + 'tasks/get': GetTaskRequestSchema, + 'tasks/result': GetTaskPayloadRequestSchema, + 'tasks/list': ListTasksRequestSchema, + 'tasks/cancel': CancelTaskRequestSchema, + 'sampling/createMessage': CreateMessageRequestSchema, + 'elicitation/create': ElicitRequestSchema, + 'roots/list': ListRootsRequestSchema +} as const; + +/** The exact 2025-era notification-method → schema map. */ +const EXPECTED_NOTIFICATION_SCHEMAS = { + 'notifications/cancelled': CancelledNotificationSchema, + 'notifications/progress': ProgressNotificationSchema, + 'notifications/initialized': InitializedNotificationSchema, + 'notifications/roots/list_changed': RootsListChangedNotificationSchema, + 'notifications/tasks/status': TaskStatusNotificationSchema, + 'notifications/message': LoggingMessageNotificationSchema, + 'notifications/resources/updated': ResourceUpdatedNotificationSchema, + 'notifications/resources/list_changed': ResourceListChangedNotificationSchema, + 'notifications/tools/list_changed': ToolListChangedNotificationSchema, + 'notifications/prompts/list_changed': PromptListChangedNotificationSchema, + 'notifications/elicitation/complete': ElicitationCompleteNotificationSchema +} as const; + +/** + * The exact 2025-era result map (the runtime/typed ALIGNED map — every entry + * is the plain schema `ResultTypeMap` declares; identity-pinned by reference). + */ +const EXPECTED_RESULT_SCHEMAS = { + ping: EmptyResultSchema, + initialize: InitializeResultSchema, + 'completion/complete': CompleteResultSchema, + 'logging/setLevel': EmptyResultSchema, + 'prompts/get': GetPromptResultSchema, + 'prompts/list': ListPromptsResultSchema, + 'resources/list': ListResourcesResultSchema, + 'resources/templates/list': ListResourceTemplatesResultSchema, + 'resources/read': ReadResourceResultSchema, + 'resources/subscribe': EmptyResultSchema, + 'resources/unsubscribe': EmptyResultSchema, + 'tools/call': CallToolResultSchema, + 'tools/list': ListToolsResultSchema, + 'sampling/createMessage': CreateMessageResultWithToolsSchema, + 'elicitation/create': ElicitResultSchema, + 'roots/list': ListRootsResultSchema +} as const; + +/** + * Task methods: served by the request map (2025 wire vocabulary, param-side + * tolerance) but deliberately ABSENT from the result map — `ResultTypeMap` + * excludes them, so the runtime map must too; callers needing task interop + * pass an explicit result schema (the documented overload). + */ +const TASK_REQUEST_METHODS = ['tasks/get', 'tasks/result', 'tasks/list', 'tasks/cancel'] as const; + +/** Methods that must NOT be in the 2025-era registries (2026-only vocabulary). */ +const NOT_IN_2025 = ['server/discover', 'subscriptions/listen', 'notifications/subscriptions/acknowledged'] as const; + +describe('2025-era registry pins (suite-then-move, Q10-L2)', () => { + it('serves exactly the pinned request methods, with the pinned schema objects', () => { + for (const [method, schema] of Object.entries(EXPECTED_REQUEST_SCHEMAS)) { + expect(getRequestSchema(method), method).toBe(schema); + } + }); + + it('serves exactly the pinned notification methods, with the pinned schema objects', () => { + for (const [method, schema] of Object.entries(EXPECTED_NOTIFICATION_SCHEMAS)) { + expect(getNotificationSchema(method), method).toBe(schema); + } + }); + + it('serves the pinned result entries by reference (aligned: plain schemas, no unions)', () => { + for (const [method, schema] of Object.entries(EXPECTED_RESULT_SCHEMAS)) { + expect(getResultSchema(method), method).toBe(schema); + } + }); + + it('serves task requests but has no task result entries (explicit-schema interop)', () => { + for (const method of TASK_REQUEST_METHODS) { + expect(getRequestSchema(method), method).toBeDefined(); + expect(getResultSchema(method), method).toBeUndefined(); + } + }); + + it('returns undefined for non-spec and 2026-only methods', () => { + for (const method of [...NOT_IN_2025, 'acme/custom', 'notifications/acme']) { + expect(getRequestSchema(method), method).toBeUndefined(); + expect(getResultSchema(method), method).toBeUndefined(); + expect(getNotificationSchema(method), method).toBeUndefined(); + } + }); + + it('the registries contain nothing beyond the pinned method sets', () => { + // Completeness guard in the inverse direction: enumerating the maps + // through their module surface must not reveal extra methods. + const requestMethods = Object.keys(EXPECTED_REQUEST_SCHEMAS).sort(); + const notificationMethods = Object.keys(EXPECTED_NOTIFICATION_SCHEMAS).sort(); + const resultMethods = Object.keys(EXPECTED_RESULT_SCHEMAS).sort(); + expect(requestMethods).toHaveLength(20); + expect(notificationMethods).toHaveLength(11); + expect(resultMethods).toHaveLength(16); + // The result-method set is exactly the request-method set minus the + // four task methods (runtime/typed alignment). + expect(resultMethods).toEqual(requestMethods.filter(method => !method.startsWith('tasks/'))); + }); +}); diff --git a/packages/core/test/types/schemaBoundaryPins.test.ts b/packages/core/test/types/schemaBoundaryPins.test.ts new file mode 100644 index 0000000000..a814ef36f8 --- /dev/null +++ b/packages/core/test/types/schemaBoundaryPins.test.ts @@ -0,0 +1,294 @@ +/** + * Behavior-surface pins: the strict/strip/loose line each wire schema draws, + * plus key-existence checks for result members consumers read by name. + * + * The Zod schemas draw a deliberate accept/strip/reject boundary at each layer: + * JSON-RPC envelopes are strict, empty-result acks are strict, typed request + * params strip unknown siblings, and typed results pass unknown siblings + * through to the consumer. An additive protocol revision must not silently + * move that line — these pins make any move loud. A failing pin here means the + * change is deliberate: update the pin together with a changeset and a + * migration-doc entry. + * + * See docs/behavior-surface-pins.md for the maintenance protocol. + */ +import { describe, expect, test } from 'vitest'; + +import { + CallToolRequestSchema, + CallToolResultSchema, + ClientCapabilitiesSchema, + CompleteResultSchema, + EmptyResultSchema, + JSONRPCErrorResponseSchema, + JSONRPCNotificationSchema, + JSONRPCRequestSchema, + JSONRPCResultResponseSchema, + ResultSchema +} from '../../src/types/index.js'; +// The per-request envelope is wire-only vocabulary and now lives in the +// 2026-era wire module (Q1 increment 2); its accept/reject line is unchanged. +import { + ClientCapabilities2026Schema, + ListToolsResultSchema as Wire2026ListToolsResultSchema, + RequestMetaEnvelopeSchema +} from '../../src/wire/rev2026-07-28/schemas.js'; +import type { + CallToolResult, + CompleteResult, + GetPromptResult, + InitializeResult, + ListPromptsResult, + ListResourcesResult, + ListResourceTemplatesResult, + ListToolsResult, + ReadResourceResult, + ServerCapabilities +} from '../../src/types/index.js'; + +/** Extract zod issue codes without depending on zod's generics. */ +const issueCodes = (err: unknown): string[] => ((err as { issues?: Array<{ code: string }> }).issues ?? []).map(i => i.code); + +describe('JSON-RPC envelope schemas are strict', () => { + test('a request with an unknown top-level sibling is rejected', () => { + const parsed = JSONRPCRequestSchema.safeParse({ jsonrpc: '2.0', id: 1, method: 'ping', params: {}, extraTop: true }); + expect(parsed.success).toBe(false); + expect(issueCodes(parsed.error)).toContain('unrecognized_keys'); + }); + + test('a notification with an unknown top-level sibling is rejected', () => { + const parsed = JSONRPCNotificationSchema.safeParse({ jsonrpc: '2.0', method: 'notifications/initialized', extraTop: true }); + expect(parsed.success).toBe(false); + expect(issueCodes(parsed.error)).toContain('unrecognized_keys'); + }); + + test('a result response with an unknown top-level sibling is rejected', () => { + const parsed = JSONRPCResultResponseSchema.safeParse({ jsonrpc: '2.0', id: 1, result: {}, extraTop: true }); + expect(parsed.success).toBe(false); + expect(issueCodes(parsed.error)).toContain('unrecognized_keys'); + }); + + test('an error response with an unknown top-level sibling is rejected', () => { + const parsed = JSONRPCErrorResponseSchema.safeParse({ + jsonrpc: '2.0', + id: 1, + error: { code: -32600, message: 'nope' }, + extraTop: true + }); + expect(parsed.success).toBe(false); + expect(issueCodes(parsed.error)).toContain('unrecognized_keys'); + }); +}); + +describe('EmptyResultSchema is strict', () => { + test('an extra non-declared field rejects', () => { + const parsed = EmptyResultSchema.safeParse({ ok: true }); + expect(parsed.success).toBe(false); + expect(issueCodes(parsed.error)).toContain('unrecognized_keys'); + }); + + test('the declared _meta member is accepted; resultType now rejects (deliberate flip)', () => { + expect(EmptyResultSchema.safeParse({}).success).toBe(true); + expect(EmptyResultSchema.safeParse({ _meta: { note: 'x' } }).success).toBe(true); + // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): `resultType` was cut + // from the base ResultSchema, so the strict empty-result ack now + // REJECTS `{resultType}` bodies at the schema level. On the protocol + // path this is invisible for conforming peers: the era codec consumes + // (2026) or strips (2025, Q1-SD3 ii) the wire member before any + // schema validation runs. Changeset: codec-split-wire-break; + // docs/migration.md "Wire schemas no longer model resultType". + expect(EmptyResultSchema.safeParse({ resultType: 'complete' }).success).toBe(false); + }); +}); + +describe('typed request params strip unknown siblings', () => { + test('an unknown sibling next to declared tools/call params is accepted and stripped', () => { + const parsed = CallToolRequestSchema.parse({ + method: 'tools/call', + params: { name: 'echo', arguments: {}, future2099: 1 } + }); + expect(parsed.params.name).toBe('echo'); + expect('future2099' in parsed.params).toBe(false); + }); +}); + +describe('typed result schemas are loose', () => { + test('the base ResultSchema no longer declares resultType (the masking surface is gone)', () => { + // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): the optional + // `resultType` member that every legacy-leg parse silently accepted + // is cut. The key still passes the loose parse as a FOREIGN sibling + // (guards are consumer-side value checks, not wire validators), but + // no neutral schema declares it; on the protocol path the 2025-era + // codec strips it on lift (Q1-SD3 ii) and the 2026-era codec consumes + // it. Changeset: codec-split-wire-break. + const parsed = ResultSchema.parse({ resultType: 'complete', futureField: 'kept' }); + expect('resultType' in parsed).toBe(true); // loose passthrough, undeclared + expect((parsed as Record).futureField).toBe('kept'); + expect(Object.keys(ResultSchema.shape)).toEqual(['_meta']); + }); + + test('unknown top-level siblings on a tools/call result survive the parse', () => { + const parsed = CallToolResultSchema.parse({ + content: [{ type: 'text', text: 'metered' }], + resultType: 'complete', + ttlMs: 5 + }); + expect(parsed.content).toEqual([{ type: 'text', text: 'metered' }]); + expect((parsed as Record).resultType).toBe('complete'); // undeclared foreign key, loose passthrough + expect((parsed as Record).ttlMs).toBe(5); + }); + + test('CallToolResult requires content on the wire (the silent-empty-success default is gone)', () => { + // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): `content.default([])` + // was removed from the wire schema. The default was the T6 width-leak + // root: a task-shaped (or otherwise content-less) body parsed as a + // silent `{content: []}` success. Content is required by the spec in + // every revision; a content-less body now fails the parse LOUDLY. + // Changeset: codec-split-wire-break; docs/migration.md + // "tools/call results must include content". + expect(CallToolResultSchema.safeParse({ structuredContent: { ok: true } }).success).toBe(false); + const parsed = CallToolResultSchema.parse({ content: [], structuredContent: { ok: true } }); + expect(parsed.content).toEqual([]); + expect(parsed.structuredContent).toEqual({ ok: true }); + }); + + test('CallToolResult preserves isError and sibling members through the parse', () => { + const parsed = CallToolResultSchema.parse({ + content: [{ type: 'text', text: 'ok' }], + structuredContent: { ok: true }, + isError: true, + _meta: { example: 'value' } + }); + expect(parsed.isError).toBe(true); + expect(parsed.structuredContent).toEqual({ ok: true }); + expect(parsed._meta).toEqual({ example: 'value' }); + expect(parsed.content).toEqual([{ type: 'text', text: 'ok' }]); + }); +}); + +describe('completion result boundary', () => { + test('the completion object is loose: unknown sibling fields are preserved', () => { + const parsed = CompleteResultSchema.parse({ completion: { values: ['alpha'], extraField: 'kept' } }); + expect(parsed.completion.values).toEqual(['alpha']); + expect((parsed.completion as Record).extraField).toBe('kept'); + }); + + test('completion.values is capped at 100 entries at the parse boundary', () => { + // The cap is receiver-side ABI: an SDK client cannot observe more than 100 + // values even from a non-SDK server that sends them. + const hundred = Array.from({ length: 100 }, (_, i) => `v${i}`); + expect(CompleteResultSchema.safeParse({ completion: { values: hundred } }).success).toBe(true); + + const overCap = CompleteResultSchema.safeParse({ completion: { values: [...hundred, 'v100'] } }); + expect(overCap.success).toBe(false); + expect(issueCodes(overCap.error)).toContain('too_big'); + }); +}); + +describe('RequestMetaEnvelopeSchema', () => { + const validEnvelope = { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'pin-client', version: '0.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {} + }; + + test('requires protocolVersion, clientInfo, and clientCapabilities', () => { + expect(RequestMetaEnvelopeSchema.safeParse(validEnvelope).success).toBe(true); + for (const key of Object.keys(validEnvelope)) { + const incomplete: Record = { ...validEnvelope }; + delete incomplete[key]; + expect(RequestMetaEnvelopeSchema.safeParse(incomplete).success).toBe(false); + } + }); + + test('is loose: foreign _meta keys pass through', () => { + const parsed = RequestMetaEnvelopeSchema.parse({ ...validEnvelope, 'com.example/custom': 'kept' }); + expect((parsed as Record)['com.example/custom']).toBe('kept'); + }); + + test('clientCapabilities are validated with the 2026 fork: tasks is not vocabulary on this revision', () => { + // The envelope composes ClientCapabilities2026Schema (the shared + // shape minus the deleted `tasks` key), matching the server-side + // fork wired into DiscoverResultSchema. A tasks-bearing claim is + // foreign vocabulary: it neither validates as a capability (a + // malformed value cannot reject the envelope) nor survives the parse. + const withMalformedTasks = { + ...validEnvelope, + 'io.modelcontextprotocol/clientCapabilities': { tasks: 'not-an-object' } + }; + expect(RequestMetaEnvelopeSchema.safeParse(withMalformedTasks).success).toBe(true); + + const parsed = RequestMetaEnvelopeSchema.parse({ + ...validEnvelope, + 'io.modelcontextprotocol/clientCapabilities': { sampling: {}, tasks: { requests: {} } } + }); + const capabilities = parsed['io.modelcontextprotocol/clientCapabilities'] as Record; + expect(capabilities.sampling).toEqual({}); + expect('tasks' in capabilities).toBe(false); + }); + + test('the 2026 client-capabilities fork tracks the shared shape exactly (minus tasks, by reference)', () => { + // The fork lists its members explicitly (dts-rollup determinism — see + // rev2026-07-28/schemas.ts); this oracle keeps the explicit list from + // drifting: same keys as the shared schema minus `tasks`, and every + // member is the SAME schema object, composed by reference. + const sharedKeys = Object.keys(ClientCapabilitiesSchema.shape).filter(key => key !== 'tasks'); + expect(Object.keys(ClientCapabilities2026Schema.shape)).toEqual(sharedKeys); + for (const key of sharedKeys) { + expect( + (ClientCapabilities2026Schema.shape as Record)[key], + `member '${key}' must be composed by reference from the shared shape` + ).toBe((ClientCapabilitiesSchema.shape as Record)[key]); + } + }); +}); + +describe('2026 wire result members', () => { + test('ttlMs is an integer at the wire boundary (anchor parity: the twin says integer)', () => { + // Type-level parity is structurally blind to this (TS can only say + // `number`), so pin it at the runtime boundary. + const base = { resultType: 'complete', ttlMs: 1500, cacheScope: 'public', tools: [] }; + expect(Wire2026ListToolsResultSchema.safeParse(base).success).toBe(true); + expect(Wire2026ListToolsResultSchema.safeParse({ ...base, ttlMs: 1500.5 }).success).toBe(false); + }); +}); + +// ---- Key-existence checks for consumer-read result members ---- +// +// Mutual-assignability checks against the spec types cannot catch a rename or +// removal of an OPTIONAL member on a loose result type: the old key is absorbed +// by the catchall index signature and the renamed key is optional, so the +// assignment compiles in both directions. Consumers read the members below by +// name, so each must remain a *declared* key of the SDK type. KnownKeyOf strips +// string/number index signatures so that only declared keys count. +type KnownKeyOf = keyof { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] }; + +const abiKeys = + () => + & string>(...keys: K[]): K[] => + keys; + +const sdkKeyExistenceChecks = { + CallToolResult: abiKeys()('content', 'structuredContent', 'isError', '_meta'), + InitializeResult: abiKeys()('protocolVersion', 'capabilities', 'serverInfo', 'instructions'), + ServerCapabilities: abiKeys()('experimental', 'completions', 'logging', 'prompts', 'resources', 'tools'), + ListToolsResult: abiKeys()('tools', 'nextCursor'), + ListResourcesResult: abiKeys()('resources', 'nextCursor'), + ListResourceTemplatesResult: abiKeys()('resourceTemplates', 'nextCursor'), + ListPromptsResult: abiKeys()('prompts', 'nextCursor'), + GetPromptResult: abiKeys()('messages'), + ReadResourceResult: abiKeys()('contents'), + CompleteResult: abiKeys()('completion') +}; + +describe('key existence for consumer-read result members', () => { + test('every consumer-read member remains a declared key of its SDK type', () => { + // The compile of `sdkKeyExistenceChecks` above IS the assertion: a renamed + // or removed member fails typecheck. The runtime check guards the table + // itself against accidental truncation. + expect(sdkKeyExistenceChecks.CallToolResult).toEqual(['content', 'structuredContent', 'isError', '_meta']); + for (const keys of Object.values(sdkKeyExistenceChecks)) { + expect(keys.length).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/core/test/types/specTypeSchema.test.ts b/packages/core/test/types/specTypeSchema.test.ts index 198e104f9f..7a077717cf 100644 --- a/packages/core/test/types/specTypeSchema.test.ts +++ b/packages/core/test/types/specTypeSchema.test.ts @@ -90,15 +90,20 @@ describe('isSpecType', () => { } }); - it('narrows to the input type, not the output type, for schemas with defaults', () => { - const v: unknown = {}; + it('CallToolResult requires content at the boundary (the wire default was removed)', () => { + // BEHAVIOR MIGRATION (Q1 increment 2, ledgered): CallToolResultSchema + // lost `content.default([])` — the silent-empty-success masking root. + // The guard's input shape now requires content, matching the spec in + // every revision. Changeset: codec-split-wire-break. + const empty: unknown = {}; + expect(isSpecType.CallToolResult(empty)).toBe(false); + const v: unknown = { content: [] }; expect(isSpecType.CallToolResult(v)).toBe(true); if (isSpecType.CallToolResult(v)) { - // CallToolResultSchema has `content: z.array(...).default([])`, so the input type - // permits `content` to be absent. The guard narrows to that input shape. - expectTypeOf(v.content).toEqualTypeOf(); - expectTypeOf(v).not.toEqualTypeOf(); + expectTypeOf(v.content).toEqualTypeOf(); + expectTypeOf(v.content).not.toEqualTypeOf(); } + void (0 as unknown as CallToolResult); }); it('JSONValue / JSONObject — narrows to the JSON type, not unknown', () => { @@ -134,7 +139,16 @@ describe('SpecTypeName / SpecTypes (type-level)', () => { }); it('SpecTypes[K] matches the named export type', () => { + // RE-SCOPE (Q1 increment 2, ledgered): specTypeSchemas now validate + // the NEUTRAL model. Result entries no longer carry the wire-only + // `resultType` member — the strip-then-equal pin from the public-face + // cut reverts to plain equality, and per-revision wire validators are + // deliberately NOT public surface (addable later via the versioned + // zod-schemas exports). Changeset: codec-split-wire-break. expectTypeOf().toEqualTypeOf(); + type KnownKeys = keyof { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] }; + type DeclaresResultType = 'resultType' extends KnownKeys ? true : false; + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); diff --git a/packages/core/test/types/wireOnlyHiding.test.ts b/packages/core/test/types/wireOnlyHiding.test.ts new file mode 100644 index 0000000000..1a71e600cc --- /dev/null +++ b/packages/core/test/types/wireOnlyHiding.test.ts @@ -0,0 +1,170 @@ +/** + * Public-face hiding pins: wire-only members and task vocabulary. + * + * Two contracts, enforced at the type level: + * + * 1. Wire-only members are absent from every public result type. `resultType` + * is the 2026-07-28 wire discrimination field; the SDK consumes it at the + * protocol layer and the public types do not declare it. The wire schemas + * keep modeling it internally (also pinned here, so the internal surface + * cannot drift silently either). + * + * 2. Task types are importable, deprecated wire vocabulary that appears in NO + * API signature: the typed method surface (RequestMethod/RequestTypeMap/ + * ResultTypeMap/NotificationTypeMap and everything built on them) offers + * no task method, and the only public declarations naming task types are + * the deprecated vocabulary cluster itself plus the exclusion helpers that + * subtract the task methods from the maps. + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, expectTypeOf, test } from 'vitest'; +import type * as z from 'zod/v4'; + +import type { + CallToolResult, + CancelTaskResult, + CompleteResult, + CreateMessageResult, + CreateMessageResultWithTools, + CreateTaskResult, + ElicitResult, + EmptyResult, + GetTaskResult, + InitializeResult, + JSONRPCResultResponse, + ListRootsResult, + ListTasksResult, + ListToolsResult, + NotificationMethod, + ReadResourceResult, + RequestMethod, + RequestTypeMap, + Result, + ResultTypeMap, + Task, + TaskAugmentedRequestParams +} from '../../src/types/types.js'; +import { CallToolResultSchema, ResultSchema } from '../../src/types/schemas.js'; + +/** Declared (non-index-signature) keys of T. */ +type KnownKeyOf = keyof { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] }; + +type DeclaresResultType = 'resultType' extends KnownKeyOf ? true : false; + +describe('wire-only members are hidden from the public result types', () => { + test('no public result type declares resultType', () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + // Deprecated task results are public vocabulary and equally stripped. + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + // The response envelope embeds the public Result, not the wire shape. + expectTypeOf>().toEqualTypeOf(); + + // Value-assignability is untouched: handler-built results may still + // carry the member through the loose index signature (raw bytes can + // always carry it; the protocol layer owns it). + const handlerBuilt: CallToolResult = { content: [], resultType: 'complete' }; + expect(handlerBuilt).toBeDefined(); + }); + + test('no neutral schema models resultType any more (the masking surface is dead)', () => { + // Q1 increment 2 (ledgered): the shared schema set carried an + // optional resultType on every result parse — the masking surface. + // Post-split, NO neutral schema declares it; the member exists only + // inside the 2026-era wire codec module. Changeset: + // codec-split-wire-break. + expectTypeOf>>().toEqualTypeOf(); + expectTypeOf>>().toEqualTypeOf(); + }); +}); + +describe('task vocabulary is importable but in no API signature', () => { + test('the typed method surface offers no task method', () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('method-keyed results are plain (no unreachable task members)', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + test('task types stay importable as wire vocabulary', () => { + // The type-only imports above are the proof; spot-check their shapes. + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf<'task' | '_meta'>(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('every task type export is tagged @deprecated at the source', () => { + const source = readFileSync(join(__dirname, '..', '..', 'src', 'types', 'types.ts'), 'utf8'); + const taskExports = [...source.matchAll(/export type (\w*Task\w*) /g)].map(match => match[1]); + expect(taskExports.length).toBeGreaterThanOrEqual(17); + for (const name of taskExports) { + const declaration = source.indexOf(`export type ${name} `); + const preceding = source.slice(Math.max(0, declaration - 400), declaration); + expect(preceding, `'${name}' must carry an @deprecated tag`).toContain('@deprecated'); + } + + const guards = readFileSync(join(__dirname, '..', '..', 'src', 'types', 'guards.ts'), 'utf8'); + const guardDecl = guards.indexOf('export const isTaskAugmentedRequestParams'); + expect(guards.slice(Math.max(0, guardDecl - 500), guardDecl)).toContain('@deprecated'); + }); + + test('the task Zod schemas and the related-task meta key carry @deprecated too', () => { + // The migration docs claim the FULL task wire surface is deprecated — + // schemas and constants included, not just the inferred types. The + // task MESSAGE schemas live in the 2025-era wire module since the + // codec split (Q1 increment 2); the param-side carriers stay in the + // neutral file. Both homes are scanned — the combined surface is the + // same ≥19 schemas the docs claim covers. + const neutral = readFileSync(join(__dirname, '..', '..', 'src', 'types', 'schemas.ts'), 'utf8'); + const wire2025 = readFileSync(join(__dirname, '..', '..', 'src', 'wire', 'rev2025-11-25', 'schemas.ts'), 'utf8'); + let total = 0; + for (const schemas of [neutral, wire2025]) { + const schemaExports = [...schemas.matchAll(/export const (\w*Tasks?\w*Schema) /g)].map(match => match[1]); + total += schemaExports.length; + for (const name of schemaExports) { + const declaration = schemas.indexOf(`export const ${name} `); + const preceding = schemas.slice(Math.max(0, declaration - 400), declaration); + expect(preceding, `'${name}' must carry an @deprecated tag`).toContain('@deprecated'); + } + } + expect(total).toBeGreaterThanOrEqual(19); + const schemas = neutral; + + // The `tasks` capability keys on both capability objects. + for (const member of ['tasks: ClientTasksCapabilitySchema.optional()', 'tasks: ServerTasksCapabilitySchema.optional()']) { + const declaration = schemas.indexOf(member); + expect(declaration, `capability member '${member}' must exist`).toBeGreaterThan(-1); + expect(schemas.slice(Math.max(0, declaration - 300), declaration), `'${member}' must carry an @deprecated tag`).toContain( + '@deprecated' + ); + } + + const constants = readFileSync(join(__dirname, '..', '..', 'src', 'types', 'constants.ts'), 'utf8'); + const keyDecl = constants.indexOf('export const RELATED_TASK_META_KEY'); + expect(constants.slice(Math.max(0, keyDecl - 300), keyDecl)).toContain('@deprecated'); + }); +}); + +// A generated-declaration scan (no task type name in any public signature) used +// to live here; the type-level exclusion tests above pin the same contract +// directly against the source types, so the substance stays covered. diff --git a/packages/core/test/wire/encodeContract.test.ts b/packages/core/test/wire/encodeContract.test.ts new file mode 100644 index 0000000000..572376fb01 --- /dev/null +++ b/packages/core/test/wire/encodeContract.test.ts @@ -0,0 +1,201 @@ +/** + * 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 }], + ['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) => { + 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 }); + }); + + 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)', () => { + 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/eraGates.test.ts b/packages/core/test/wire/eraGates.test.ts new file mode 100644 index 0000000000..8776ee69c0 --- /dev/null +++ b/packages/core/test/wire/eraGates.test.ts @@ -0,0 +1,609 @@ +/** + * Physical deletions through real dispatch (Q1 increment 2). + * + * Era is INSTANCE state: the negotiated protocol version held by the + * Protocol instance selects the wire codec for everything the connection + * sends and receives. Legacy is the default (hand-constructed instances and + * pre-negotiation traffic); modern-era instances get their version set + * through the package-internal hook (`setNegotiatedProtocolVersion`) — the + * same channel the modern-era server entry will use at instance binding. + * + * Registry membership is the deletion story, and these tests prove it at the + * protocol funnels, in both directions: + * + * - inbound: `tasks/get` on a modern-era instance gets −32601 BY ABSENCE — + * even with a handler registered (a custom handler cannot shadow a + * deleted spec method across eras); era-deleted spec notifications are + * silently dropped even with a handler registered. + * - outbound: an era-mismatched spec method dies locally with + * `SdkErrorCode.MethodNotSupportedByProtocolVersion` before anything + * reaches the transport. + * - the 2026 era requires the per-request envelope (−32602 when missing). + * - the stamp seam: 2026-era responses carry `resultType: 'complete'`; + * 2025-era responses NEVER carry it (the 2025 codec has no stamp code + * path — the never-stamp guarantee). + * - encode-side deleted-field strictness (Q1-SD3 iii): `execution` is + * stripped from tools and `tasks` from capability objects on 2026-era + * emissions; both survive untouched on the 2025 era. + * + * `MessageExtraInfo.classification` (INJECTED here; the production + * classifier is the entry/edge's job) no longer selects the era per message: + * the funnel VALIDATES it against the instance era — a mismatch is an + * entry/routing error (typed −32004 rejection / notification drop, plus + * onerror), and unclassified traffic on a legacy instance behaves exactly as + * before the codec split (the B-2 rule). + */ +import { describe, expect, test } from 'vitest'; + +import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol, setNegotiatedProtocolVersion } from '../../src/shared/protocol.js'; +import type { JSONRPCMessage, MessageClassification, Result } from '../../src/types/index.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; +import * as z from 'zod/v4'; + +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: 'era-client', version: '0.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {} +}; + +interface Harness { + receiver: TestProtocol; + /** Deliver a raw message to the receiver, optionally classified. */ + deliver: (message: JSONRPCMessage, classification?: MessageClassification) => void; + /** Messages the receiver sent back (responses, notifications). */ + sent: JSONRPCMessage[]; + /** Out-of-band errors surfaced via the receiver's onerror. */ + errors: Error[]; + flush: () => Promise; +} + +interface HarnessOptions { + /** + * Marks the instance's era through the package-internal hook (the same + * channel the modern-era server entry uses at instance binding). Omitted + * = legacy default, exactly like a hand-constructed instance. + */ + era?: '2025-11-25' | '2026-07-28'; + setup?: (receiver: TestProtocol) => void; +} + +async function harness(options: HarnessOptions = {}): Promise { + const [peerTx, receiverTx] = InMemoryTransport.createLinkedPair(); + const sent: JSONRPCMessage[] = []; + peerTx.onmessage = message => void sent.push(message); + await peerTx.start(); + + const receiver = new TestProtocol(); + const errors: Error[] = []; + receiver.onerror = error => void errors.push(error); + options.setup?.(receiver); + if (options.era !== undefined) setNegotiatedProtocolVersion(receiver, options.era); + await receiver.connect(receiverTx); + + return { + receiver, + // Invoke the receiver-side transport callback directly so the test + // controls MessageExtraInfo (the classification handoff seam). + deliver: (message, classification) => receiverTx.onmessage?.(message, classification ? ({ classification } as never) : undefined), + sent, + errors, + flush: () => new Promise(resolve => setTimeout(resolve, 10)) + }; +} + +const errorOf = (msg: JSONRPCMessage | undefined) => (msg as { error?: { code: number; message: string } } | undefined)?.error; +const resultOf = (msg: JSONRPCMessage | undefined) => (msg as { result?: Record } | undefined)?.result; + +describe('inbound era gates — deletions are physical, era is instance state', () => { + const registerTasksGetHandler = (onRun: () => void) => (receiver: TestProtocol) => { + // A custom (3-arg) handler deliberately shadowing the deleted + // spec method: it may serve the 2025 era only. + receiver.setRequestHandler('tasks/get', { params: z.looseObject({ taskId: z.string() }) }, () => { + onRun(); + return {} as Result; + }); + }; + + test('a modern-era instance answers tasks/get with −32601 BY ABSENCE even with a handler registered', async () => { + let handlerRan = false; + const h = await harness({ era: '2026-07-28', setup: registerTasksGetHandler(() => (handlerRan = true)) }); + + // A matching modern classification rides along untouched — the + // handoff check accepts it; the era gate still answers by absence. + h.deliver( + { jsonrpc: '2.0', id: 1, method: 'tasks/get', params: { taskId: 't-1', _meta: { ...ENVELOPE } } } as JSONRPCMessage, + MODERN + ); + await h.flush(); + + expect(handlerRan).toBe(false); + expect(h.sent).toHaveLength(1); + expect(errorOf(h.sent[0])).toMatchObject({ code: -32601, message: 'Method not found' }); + }); + + test('a legacy-era instance (the default) serves tasks/get with that handler — era is fixed per instance', async () => { + let handlerRan = false; + const h = await harness({ setup: registerTasksGetHandler(() => (handlerRan = true)) }); + + // Unclassified, hand-wired instance ⇒ legacy default (B-2): exactly + // the pre-split behavior. + h.deliver({ jsonrpc: '2.0', id: 2, method: 'tasks/get', params: { taskId: 't-1' } } as JSONRPCMessage); + await h.flush(); + + expect(handlerRan).toBe(true); + expect(resultOf(h.sent[0])).toBeDefined(); + }); + + test('ping on a modern-era instance is −32601 by absence (the built-in pong cannot cross eras)', async () => { + const modern = await harness({ era: '2026-07-28' }); + modern.deliver({ jsonrpc: '2.0', id: 3, method: 'ping', params: { _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN); + await modern.flush(); + expect(errorOf(modern.sent[0])).toMatchObject({ code: -32601 }); + + // …while a legacy-era instance keeps the automatic pong. + const legacy = await harness(); + legacy.deliver({ jsonrpc: '2.0', id: 4, method: 'ping' } as JSONRPCMessage); + await legacy.flush(); + expect(resultOf(legacy.sent[0])).toEqual({}); + }); + + test('a spec notification the modern era deleted is dropped even with a handler', async () => { + let delivered = 0; + const registerHandler = (receiver: TestProtocol) => { + receiver.setNotificationHandler('notifications/tasks/status', { params: z.looseObject({}) }, () => { + delivered += 1; + }); + }; + + const modern = await harness({ era: '2026-07-28', setup: registerHandler }); + modern.deliver( + { jsonrpc: '2.0', method: 'notifications/tasks/status', params: { taskId: 't', status: 'working' } } as JSONRPCMessage, + MODERN + ); + await modern.flush(); + expect(delivered).toBe(0); + + // Legacy-era instance: delivered. + const legacy = await harness({ setup: registerHandler }); + legacy.deliver({ + jsonrpc: '2.0', + method: 'notifications/tasks/status', + params: { taskId: 't', status: 'working' } + } as JSONRPCMessage); + await legacy.flush(); + expect(delivered).toBe(1); + }); + + test('out-of-universe custom methods stay era-blind (consumer-owned)', async () => { + let served = 0; + const registerHandler = (receiver: TestProtocol) => { + receiver.setRequestHandler('acme/anything', { params: z.looseObject({}) }, () => { + served += 1; + return {} as Result; + }); + }; + + // Served on a modern-era instance (envelope present, as 2026 requires)… + const modern = await harness({ era: '2026-07-28', setup: registerHandler }); + modern.deliver({ jsonrpc: '2.0', id: 5, method: 'acme/anything', params: { _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN); + // …and on a legacy-era instance, bare: the era gate never blocks + // methods outside the spec universe on either era. + const legacy = await harness({ setup: registerHandler }); + legacy.deliver({ jsonrpc: '2.0', id: 6, method: 'acme/anything', params: {} } as JSONRPCMessage); + + await modern.flush(); + await legacy.flush(); + expect(served).toBe(2); + }); +}); + +describe('2026-era envelope requiredness at dispatch', () => { + test('a modern-era request without the envelope is −32602 naming the requirement', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler('tools/list', () => ({ tools: [] })); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} } as JSONRPCMessage, MODERN); + await h.flush(); + + const error = errorOf(h.sent[0]); + expect(error?.code).toBe(-32602); + expect(error?.message).toContain('_meta envelope'); + }); + + test('a modern-era request with a valid envelope is served (handler sees the 2025 shape)', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler('tools/list', () => ({ tools: [] })); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: { _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN); + await h.flush(); + + expect(resultOf(h.sent[0])).toMatchObject({ tools: [] }); + }); + + test('the 2025 era never requires an envelope', async () => { + const h = await harness({ + setup: receiver => { + receiver.setRequestHandler('tools/list', () => ({ tools: [] })); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} } as JSONRPCMessage); + await h.flush(); + expect(resultOf(h.sent[0])).toMatchObject({ tools: [] }); + }); + + test('−32601 outranks the missing envelope: unknown/era-deleted/unserved methods answer method-not-found', async () => { + // Method existence outranks parameter validity (the canonical + // precedence table for the full inbound validation ladder arrives + // with the validation-ladder milestone; this pins the + // −32601-over-−32602 rule on the modern leg). All three −32601 + // producers win over the envelope −32602: + const h = await harness({ era: '2026-07-28' }); + + // (a) out-of-universe method, no handler registered; + h.deliver({ jsonrpc: '2.0', id: 4, method: 'acme/no-such-method', params: {} } as JSONRPCMessage, MODERN); + // (b) spec method deleted from the era (the era gate runs first); + h.deliver({ jsonrpc: '2.0', id: 5, method: 'tasks/get', params: { taskId: 't-1' } } as JSONRPCMessage, MODERN); + // (c) spec method IN era but with no handler registered. + h.deliver({ jsonrpc: '2.0', id: 6, method: 'tools/list', params: {} } as JSONRPCMessage, MODERN); + await h.flush(); + + expect(h.sent).toHaveLength(3); + for (const message of h.sent) { + expect(errorOf(message)).toMatchObject({ code: -32601, message: 'Method not found' }); + } + }); +}); + +describe('the stamp seam and the never-stamp guarantee', () => { + test('2026-era responses are stamped resultType: complete', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler('tools/list', () => ({ tools: [] })); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN); + await h.flush(); + + expect(resultOf(h.sent[0])).toMatchObject({ resultType: 'complete' }); + }); + + test('2025-era responses NEVER carry resultType (no stamp code path exists)', async () => { + const h = await harness({ + setup: receiver => { + receiver.setRequestHandler('tools/list', () => ({ tools: [] })); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} } as JSONRPCMessage); + await h.flush(); + + const result = resultOf(h.sent[0]); + expect(result).toBeDefined(); + expect(result && 'resultType' in result).toBe(false); + }); + + test('the 2025 codec encodeResult is the identity (same reference, nothing added)', async () => { + const { rev2025Codec } = await import('../../src/wire/rev2025-11-25/codec.js'); + const result = { content: [{ type: 'text', text: 'x' }] } as unknown as Result; + expect(rev2025Codec.encodeResult('tools/call', result)).toBe(result); + }); +}); + +describe('encode-side deleted-field strictness (Q1-SD3 iii)', () => { + const TOOL_WITH_EXECUTION = { + name: 'legacy-tool', + inputSchema: { type: 'object' }, + execution: { taskSupport: 'optional' } + }; + + test('execution.taskSupport is stripped from 2026-era tools/list emissions', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler('tools/list', (() => ({ tools: [TOOL_WITH_EXECUTION] })) as never); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN); + await h.flush(); + + const tools = resultOf(h.sent[0])?.tools as Array>; + expect(tools[0]).toMatchObject({ name: 'legacy-tool' }); + expect('execution' in tools[0]!).toBe(false); + }); + + test('the same handler emits execution untouched on the 2025 era (era-invisible handlers)', async () => { + const h = await harness({ + setup: receiver => { + receiver.setRequestHandler('tools/list', (() => ({ tools: [TOOL_WITH_EXECUTION] })) as never); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} } as JSONRPCMessage); + await h.flush(); + + const tools = resultOf(h.sent[0])?.tools as Array>; + expect(tools[0]).toMatchObject({ name: 'legacy-tool', execution: { taskSupport: 'optional' } }); + }); + + test('capabilities.tasks is stripped from 2026-era capability-carrying emissions (server/discover)', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler( + 'server/discover' as never, + (() => ({ + ttlMs: 0, + cacheScope: 'private', + supportedVersions: ['2026-07-28'], + capabilities: { tools: {}, tasks: { list: {} } }, + serverInfo: { name: 's', version: '0' } + })) as never + ); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 3, method: 'server/discover', params: { _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN); + await h.flush(); + + const result = resultOf(h.sent[0]); + expect(result).toMatchObject({ resultType: 'complete', capabilities: { tools: {} } }); + expect('tasks' in (result?.capabilities as Record)).toBe(false); + }); +}); + +describe('the edge→instance handoff — classification is validated, never an era switch', () => { + test('a modern-classified request on a legacy-era instance is an entry/routing error: typed −32004, handler never runs', async () => { + let handlerRan = false; + const h = await harness({ + setup: receiver => { + receiver.setRequestHandler('tools/list', () => { + handlerRan = true; + return { tools: [] }; + }); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN); + await h.flush(); + + expect(handlerRan).toBe(false); + expect(h.sent).toHaveLength(1); + const error = errorOf(h.sent[0]); + expect(error?.code).toBe(-32004); + expect(error?.message).toContain('Unsupported protocol version'); + // Surfaced out of band too: the mismatch is the entry's bug, not the peer's. + expect(h.errors.some(e => e.message.includes('Era mismatch'))).toBe(true); + }); + + test('a legacy-classified request on a modern-era instance is rejected the same way', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler('tools/list', () => ({ tools: [] })); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} } as JSONRPCMessage, { + era: 'legacy', + revision: '2025-11-25' + }); + await h.flush(); + + expect(errorOf(h.sent[0])).toMatchObject({ code: -32004 }); + expect(h.errors.some(e => e.message.includes('Era mismatch'))).toBe(true); + }); + + test('the rejection’s data.requested names the exact revision the classification carried, not just the era label', async () => { + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + receiver.setRequestHandler('tools/list', () => ({ tools: [] })); + } + }); + + h.deliver({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} } as JSONRPCMessage, { + era: 'legacy', + revision: '2025-06-18' + }); + await h.flush(); + + const error = errorOf(h.sent[0]) as { code: number; data?: { requested?: string } } | undefined; + expect(error?.code).toBe(-32004); + expect(error?.data?.requested).toBe('2025-06-18'); + }); + + test('a modern-classified notification on a legacy-era instance is dropped, with onerror', async () => { + let delivered = 0; + const h = await harness({ + setup: receiver => { + receiver.setNotificationHandler('notifications/progress', () => { + delivered += 1; + }); + } + }); + + h.deliver( + { jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: 1, progress: 1 } } as JSONRPCMessage, + MODERN + ); + await h.flush(); + + expect(delivered).toBe(0); + expect(h.sent).toHaveLength(0); + expect(h.errors.some(e => e.message.includes('Era mismatch'))).toBe(true); + }); + + test('a matching classification rides along untouched (and unclassified legacy traffic is byte-identical — B-2)', async () => { + const h = await harness({ + setup: receiver => { + receiver.setRequestHandler('tools/list', () => ({ tools: [] })); + } + }); + + // Matching legacy classification. + h.deliver({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} } as JSONRPCMessage, { era: 'legacy' }); + // Unclassified (the hand-wired transport posture). + h.deliver({ jsonrpc: '2.0', id: 4, method: 'tools/list', params: {} } as JSONRPCMessage); + await h.flush(); + + expect(h.sent).toHaveLength(2); + expect(resultOf(h.sent[0])).toMatchObject({ tools: [] }); + expect(resultOf(h.sent[1])).toMatchObject({ tools: [] }); + expect(h.errors).toHaveLength(0); + }); +}); + +describe('outbound era gates — typed local error before the transport', () => { + test('a 2026-era instance cannot send 2025-only spec methods', async () => { + const h = await harness({ era: '2026-07-28' }); + + for (const method of ['tasks/get', 'ping', 'logging/setLevel', 'resources/subscribe']) { + const attempt = () => h.receiver.request({ method } as never); + expect(attempt, method).toThrow(SdkError); + try { + attempt(); + } catch (error) { + expect((error as SdkError).code, method).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + expect((error as SdkError).data, method).toMatchObject({ method, era: '2026-07-28' }); + } + } + // Nothing reached the transport. + expect(h.sent).toHaveLength(0); + }); + + test('a legacy-era instance cannot send server/discover', async () => { + const h = await harness({ era: '2025-11-25' }); + + expect(() => h.receiver.request({ method: 'server/discover' } as never)).toThrow(SdkError); + try { + h.receiver.request({ method: 'server/discover' } as never); + } catch (error) { + expect((error as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + } + expect(h.sent).toHaveLength(0); + }); + + test('outbound era-mismatched spec notifications die locally too', async () => { + const h = await harness({ era: '2026-07-28' }); + + await expect(h.receiver.notification({ method: 'notifications/roots/list_changed' })).rejects.toMatchObject({ + code: SdkErrorCode.MethodNotSupportedByProtocolVersion + }); + expect(h.sent).toHaveLength(0); + }); + + test('_requestWithSchema applies the same outbound era gate: an explicit schema never smuggles a deleted method', async () => { + const h = await harness({ era: '2026-07-28' }); + const requestWithSchema = ( + h.receiver as unknown as { + _requestWithSchema: (request: { method: string }, schema: unknown) => Promise; + } + )._requestWithSchema.bind(h.receiver); + + expect(() => requestWithSchema({ method: 'ping' }, z.object({}))).toThrow(SdkError); + try { + requestWithSchema({ method: 'ping' }, z.object({})); + } catch (error) { + expect((error as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + expect((error as SdkError).data).toMatchObject({ method: 'ping', era: '2026-07-28' }); + } + expect(h.sent).toHaveLength(0); + }); + + test('pre-negotiation bootstrap pins still route initialize to the 2025 era', async () => { + // An instance with NO negotiated version may always send the legacy + // handshake; setting a modern version afterwards closes it (the pin + // applies only while the negotiated version is unset — a negotiated + // session never re-routes onto the other era). + const h = await harness(); + const pending = h.receiver.request({ + method: 'initialize', + params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'c', version: '0' } } + }); + pending.catch(() => undefined); // unanswered; we only assert the send happened + await h.flush(); + // The handshake reached the wire (sent[] captures the peer's inbox). + expect(h.sent).toHaveLength(1); + expect((h.sent[0] as { method?: string }).method).toBe('initialize'); + await h.receiver.close(); + + const h2 = await harness({ era: '2026-07-28' }); + expect(() => + h2.receiver.request({ + method: 'initialize', + params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'c', version: '0' } } + }) + ).toThrow(SdkError); + }); +}); + +describe('T6 width-leak killed at both roots', () => { + test('2026 era: a task-shaped tools/call body can never parse as an empty success', async () => { + const { rev2026Codec } = await import('../../src/wire/rev2026-07-28/codec.js'); + // resultType present-and-complete but the body is task-shaped: the + // wire-exact parse requires content — loud invalid, never {content: []}. + const decoded = rev2026Codec.decodeResult('tools/call', { + resultType: 'complete', + task: { taskId: 't-1', status: 'working' } + }); + expect(decoded.kind).toBe('invalid'); + }); + + test('2025 era: with the content default gone, a bare task-shaped body fails the plain schema loudly', async () => { + const { rev2025Codec } = await import('../../src/wire/rev2025-11-25/codec.js'); + const { CallToolResultSchema } = await import('../../src/types/schemas.js'); + const decoded = rev2025Codec.decodeResult('tools/call', { task: { taskId: 't-1', status: 'working' } }); + expect(decoded.kind).toBe('complete'); + if (decoded.kind === 'complete') { + // The plain schema (which IS the registry entry — the result map + // is aligned to the typed map, no task-widened union): no + // default([]) means no silent {content: []} masking. + expect(CallToolResultSchema.safeParse(decoded.result).success).toBe(false); + } + // The GENERIC path agrees: the registry serves the same plain schema, + // so even a fully conforming CreateTaskResult body is a loud schema + // failure (surfaced as a typed INVALID_RESULT — see + // test/shared/typedMapAlignment.test.ts). Task interop is the + // explicit-schema overload, never a silent union member. + const { getResultSchema } = await import('../../src/wire/rev2025-11-25/registry.js'); + const plain = getResultSchema('tools/call'); + expect(plain).toBe(CallToolResultSchema); + expect( + plain!.safeParse({ + task: { + taskId: '786af6b0-2779-48ed-9cc1-b8a8a25b8a86', + status: 'working', + createdAt: '2025-11-25T10:30:00Z', + lastUpdatedAt: '2025-11-25T10:30:05Z', + ttl: 60000, + pollInterval: 5000 + } + }).success + ).toBe(false); + }); +}); diff --git a/packages/core/test/wire/neutralKeyParity.test.ts b/packages/core/test/wire/neutralKeyParity.test.ts new file mode 100644 index 0000000000..316513541b --- /dev/null +++ b/packages/core/test/wire/neutralKeyParity.test.ts @@ -0,0 +1,98 @@ +/** + * The neutralKeys pin family (Q1 increment 3): + * + * neutralKeys(T) = wireKeys@rev(T) − WIRE_ONLY + * + * For every mapped result type, the NEUTRAL public type's declared keys must + * equal the revision's WIRE type's declared keys minus the wire-only set + * (`resultType` — the envelope keys and retry fields are params-side and + * never appear on result types). This closes BOTH inherited verification + * holes at once: + * - the old 2025 suite tolerated a phantom `resultType` key on every result + * (`AssertExactKeysWithResultType`), and + * - the old 2026 suite had no key parity at all. + * + * OWNED PENDING DELTA (stale-checked): the 2026 cacheable results carry + * `ttlMs`/`cacheScope` on the wire. Those are CONSUMER-RELEVANT (cache fields + * are deliberately NOT wire-only — Q13) but the neutral model does not carry + * them until the cache-hint surface lands (M3.2/#12). Each cacheable entry + * below subtracts them explicitly; when M3.2 models them neutrally, the + * subtraction breaks the build and the entry burns. + */ +import { describe, expect, test } from 'vitest'; +import type * as z4 from 'zod/v4'; + +import type * as SDK from '../../src/types/index.js'; +import type * as Wire2026 from '../../src/wire/rev2026-07-28/schemas.js'; + +/* eslint-disable @typescript-eslint/no-unused-vars */ + +type KnownKeys = keyof { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] }; + +type AssertSameKeys = [KnownKeys] extends [KnownKeys] + ? [KnownKeys] extends [KnownKeys] + ? true + : { _brand: 'KeyMismatch'; missingFromA: Exclude, KnownKeys> } + : { _brand: 'KeyMismatch'; extraInA: Exclude, KnownKeys> }; + +type Assert = T; + +/** The wire-only key set on results (the hide set's result-side member). */ +type WIRE_ONLY = 'resultType'; + +/** M3.2-owned pending delta: cache fields modeled on the wire, not yet neutrally. */ +type M32_PENDING = 'ttlMs' | 'cacheScope'; + +type MinusWireOnly = { [K in keyof T as K extends WIRE_ONLY ? never : K]: T[K] }; +type MinusWireOnlyAndCache = { [K in keyof T as K extends WIRE_ONLY | M32_PENDING ? never : K]: T[K] }; + +/* ---- 2026: neutralKeys(T) = wireKeys@2026(T) − WIRE_ONLY ---- */ + +type _N26_Result = Assert>>>; +type _N26_EmptyResult = Assert>>>; +type _N26_CallToolResult = Assert>>>; +type _N26_CompleteResult = Assert>>>; +type _N26_GetPromptResult = Assert>>>; +// Cacheable results: ttlMs/cacheScope subtracted until M3.2 models them neutrally. +type _N26_ListToolsResult = Assert< + AssertSameKeys>> +>; +type _N26_ListPromptsResult = Assert< + AssertSameKeys>> +>; +type _N26_ListResourcesResult = Assert< + AssertSameKeys>> +>; +type _N26_ListResourceTemplatesResult = Assert< + AssertSameKeys>> +>; +type _N26_ReadResourceResult = Assert< + AssertSameKeys>> +>; +type _N26_DiscoverResult = Assert< + AssertSameKeys>> +>; + +/* ---- 2025: the wire schemas ARE the neutral schemas post-cut — pin that no + * result type re-grows a resultType slot (the masking surface stays dead). ---- */ + +type DeclaresResultType = 'resultType' extends KnownKeys ? true : false; +type _N25_Result = Assert extends false ? true : false>; +type _N25_EmptyResult = Assert extends false ? true : false>; +type _N25_CallToolResult = Assert extends false ? true : false>; +type _N25_InitializeResult = Assert extends false ? true : false>; +type _N25_CreateMessageResult = Assert extends false ? true : false>; +type _N25_ElicitResult = Assert extends false ? true : false>; +type _N25_ListRootsResult = Assert extends false ? true : false>; +type _N25_GetTaskResult = Assert extends false ? true : false>; +type _N25_ClientResult = Assert extends false ? true : false>; +type _N25_ServerResult = Assert extends false ? true : false>; + +describe('neutralKeys pin family', () => { + test('the compile of this file IS the assertion (runtime guard against truncation)', () => { + // 11 per-type 2026 pins + 10 resultType-absence pins are enforced at + // type level above; this runtime test exists so the file cannot be + // silently excluded from the suite. + expect(true).toBe(true); + }); +}); diff --git a/packages/core/test/wire/registryDiffOracle.test.ts b/packages/core/test/wire/registryDiffOracle.test.ts new file mode 100644 index 0000000000..c782e16664 --- /dev/null +++ b/packages/core/test/wire/registryDiffOracle.test.ts @@ -0,0 +1,104 @@ +/** + * Registry-diff oracle (Q1 increment 3 — generation as ORACLE, never source). + * + * The per-era method registries are HAND-WRITTEN (a generator walking anchor + * method literals would silently re-admit the 2026-demoted server→client + * methods — the flavor-(b) trap). This oracle derives each revision's method + * universe FROM THE ANCHOR SOURCE at test time and fails LOUD — with the + * exact diff — whenever the anchor and the hand registry disagree, modulo a + * documented seed-decision list that is stale-checked in both directions. + * + * Seed decisions (every entry is a deliberate, owned divergence): + * - 2026 DEMOTIONS: `sampling/createMessage`, `elicitation/create`, + * `roots/list` keep method literals in the anchor but are NOT wire request + * methods in 2026 — the server→client JSON-RPC request channel is deleted + * (`ServerRequest` has no 2026 export; the shapes survive only as in-band + * `InputRequest` payloads, M4.1/#13). + * - 2026 DEFERRALS: `subscriptions/listen` and + * `notifications/subscriptions/acknowledged` are real 2026 wire methods + * whose SHELLS land with the subscriptions feature (M6.1/#14). The day #14 + * wires them, this oracle fails until the entries are removed — that + * failure is the burn-down notification, by design. + */ +import fs from 'node:fs'; +import path from 'node:path'; + +import { describe, expect, test } from 'vitest'; + +import { rev2025NotificationMethods, rev2025RequestMethods } from '../../src/wire/rev2025-11-25/registry.js'; +import { rev2026NotificationMethods, rev2026RequestMethods } from '../../src/wire/rev2026-07-28/registry.js'; + +const ANCHORS = { + '2025-11-25': path.resolve(__dirname, '../../src/types/spec.types.2025-11-25.ts'), + '2026-07-28': path.resolve(__dirname, '../../src/types/spec.types.2026-07-28.ts') +} as const; + +/** Extract every `method: ''` from an anchor source. */ +function anchorMethods(revision: keyof typeof ANCHORS): { requests: string[]; notifications: string[] } { + const source = fs.readFileSync(ANCHORS[revision], 'utf8'); + const literals = [...source.matchAll(/method:\s*'([^']+)'/g)].map(m => m[1]!); + const unique = [...new Set(literals)].sort(); + return { + requests: unique.filter(m => !m.startsWith('notifications/')), + notifications: unique.filter(m => m.startsWith('notifications/')) + }; +} + +/** Anchor-side methods deliberately NOT in the hand registry (reason per entry). */ +const SEED_EXCLUSIONS: Record> = { + '2025-11-25': {}, + '2026-07-28': { + 'sampling/createMessage': 'DEMOTED to an in-band InputRequest payload (M4.1/#13) — not a 2026 wire request', + 'elicitation/create': 'DEMOTED to an in-band InputRequest payload (M4.1/#13) — not a 2026 wire request', + 'roots/list': 'DEMOTED to an in-band InputRequest payload (M4.1/#13) — not a 2026 wire request', + 'subscriptions/listen': 'DEFERRED to the subscriptions feature (M6.1/#14)', + 'notifications/subscriptions/acknowledged': 'DEFERRED to the subscriptions feature (M6.1/#14)' + } +}; + +const REGISTRIES = { + '2025-11-25': { requests: rev2025RequestMethods, notifications: rev2025NotificationMethods }, + '2026-07-28': { requests: rev2026RequestMethods, notifications: rev2026NotificationMethods } +} as const; + +describe.each(['2025-11-25', '2026-07-28'] as const)('registry-diff oracle %s', revision => { + const anchor = anchorMethods(revision); + const registry = REGISTRIES[revision]; + const exclusions = SEED_EXCLUSIONS[revision]!; + + test('every anchor method is in the hand registry or a documented seed exclusion', () => { + const missing = [...anchor.requests, ...anchor.notifications].filter(method => { + const inRegistry = registry.requests.includes(method) || registry.notifications.includes(method); + return !inRegistry && !(method in exclusions); + }); + expect( + missing, + `Anchor methods absent from the ${revision} registry with NO seed decision — ` + + `wire them or add a documented exclusion (this is the loud failure the oracle exists for)` + ).toEqual([]); + }); + + test('the hand registry contains nothing beyond the anchor universe', () => { + const anchorSet = new Set([...anchor.requests, ...anchor.notifications]); + const extra = [...registry.requests, ...registry.notifications].filter(method => !anchorSet.has(method)); + expect(extra, `Registry methods with no ${revision} anchor literal — era leak or typo`).toEqual([]); + }); + + test('seed exclusions are not stale (still in the anchor, still not in the registry)', () => { + for (const [method, reason] of Object.entries(exclusions)) { + const inAnchor = anchor.requests.includes(method) || anchor.notifications.includes(method); + expect(inAnchor, `${method}: exclusion no longer matches any anchor literal — remove it (${reason})`).toBe(true); + const inRegistry = registry.requests.includes(method) || registry.notifications.includes(method); + expect(inRegistry, `${method}: now wired in the registry — remove the stale exclusion (${reason})`).toBe(false); + } + }); + + test('the anchor universe is fully partitioned (sanity: counts add up)', () => { + const total = anchor.requests.length + anchor.notifications.length; + const covered = + registry.requests.filter(m => anchor.requests.includes(m)).length + + registry.notifications.filter(m => anchor.notifications.includes(m)).length + + Object.keys(exclusions).length; + expect(covered).toBe(total); + }); +}); diff --git a/packages/core/test/wire/schemaTwinConformance.test.ts b/packages/core/test/wire/schemaTwinConformance.test.ts new file mode 100644 index 0000000000..e0f4b21dca --- /dev/null +++ b/packages/core/test/wire/schemaTwinConformance.test.ts @@ -0,0 +1,126 @@ +/** + * Schema-twin conformance lock (Q1 increment 3 — generation as ORACLE). + * + * The spec repository generates `schema.json` from the same normative + * `schema.ts` the anchors vendor. The twins vendored under + * `corpus/schema-twins/` (TEST-ONLY — never bundled, never runtime; the + * engines stay optional peers and the hot path stays hand-written Zod) give + * a generated, revision-exact validator for every named spec type. This + * suite locks the hand-written wire layer to them, per revision per fixture: + * + * - every accept-corpus fixture must satisfy the GENERATED validator for its + * directory's spec type (catches twin/anchor desync and hand-corpus drift + * — the 2025 mini-corpus is hand-built, so this is its only independent + * referee), and + * - every fixture the SDK wire layer accepts must also be twin-valid + * (agreement on the accept side; reject-side deltas are owned by the + * dispatch-routed rejection corpus, since generated valid-only oracles are + * blind to them). + * + * Twin refresh is ATOMIC with the matching anchor (lifecycle rule 4, + * packages/core/src/types/README.md); provenance in schema-twins/manifest.json. + */ +import { createHash } from 'node:crypto'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +import { Ajv2020 as Ajv } from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; +import { describe, expect, test } from 'vitest'; + +const FIXTURES_ROOT = join(__dirname, '../corpus/fixtures'); +const TWINS_ROOT = join(__dirname, '../corpus/schema-twins'); + +interface TwinManifest { + source: { repository: string; commit: string }; + files: Record; +} + +const TWIN_MANIFEST = JSON.parse(readFileSync(join(TWINS_ROOT, 'manifest.json'), 'utf8')) as TwinManifest; + +describe('twin provenance integrity (the manifest lock)', () => { + // The twins' authority as generated oracles rests on them being the raw + // upstream artifacts, byte for byte. Hash the vendored files against the + // manifest's provenance values at test time so ANY rewrite — prettier, an + // editor, a manual touch-up — fails loudly. Refresh only via + // `pnpm fetch:schema-twins` (which recomputes these values from the + // fetched bytes), atomically with the matching spec.types anchor. + test.each(Object.keys(TWIN_MANIFEST.files))('%s twin is byte-identical to the upstream artifact pinned in the manifest', revision => { + const entry = TWIN_MANIFEST.files[revision]!; + const raw = readFileSync(join(TWINS_ROOT, `${revision}.schema.json`)); + expect(raw.byteLength, `byte size drifted for ${revision} — the vendored twin was rewritten`).toBe(entry.bytes); + expect( + createHash('sha256').update(raw).digest('hex'), + `sha256 drifted for ${revision} — the vendored twin was rewritten (re-vendor raw bytes via pnpm fetch:schema-twins)` + ).toBe(entry.sha256); + }); +}); + +type JsonSchema = { $defs?: Record }; + +function twinValidatorFactory(revision: string) { + const schema = JSON.parse(readFileSync(join(TWINS_ROOT, `${revision}.schema.json`), 'utf8')) as JsonSchema; + const ajv = new Ajv({ strict: false, allowUnionTypes: true }); + addFormats.default ? addFormats.default(ajv) : (addFormats as unknown as (a: Ajv) => void)(ajv); + ajv.addSchema(schema, 'spec'); + return { + defs: new Set(Object.keys(schema.$defs ?? {})), + requiredOf(typeName: string): string[] { + return schema.$defs?.[typeName]?.required ?? []; + }, + validatorFor(typeName: string) { + return ajv.getSchema(`spec#/$defs/${typeName}`); + } + }; +} + +function listTypeDirs(revision: string): string[] { + const root = join(FIXTURES_ROOT, revision); + return readdirSync(root) + .filter(entry => statSync(join(root, entry)).isDirectory()) + .sort(); +} + +function listFixtures(revision: string, dir: string): string[] { + return readdirSync(join(FIXTURES_ROOT, revision, dir)) + .filter(file => file.endsWith('.json')) + .sort(); +} + +describe.each(['2025-11-25', '2026-07-28'] as const)('schema-twin conformance lock %s', revision => { + const twin = twinValidatorFactory(revision); + const dirs = listTypeDirs(revision).filter(dir => twin.defs.has(dir)); + + test('the twin covers the corpus (the unmapped set is pinned exactly)', () => { + const unmapped = listTypeDirs(revision).filter(dir => !twin.defs.has(dir)); + // Unmapped directories would be SDK-named shapes with no spec def. + // Today there are NONE — the set is pinned exactly, not bounded with + // slack: a new unmapped directory means the twin and the corpus are + // drifting apart and must be adjudicated here by name. + expect(unmapped).toEqual([]); + expect(dirs.length).toBeGreaterThan(30); + }); + + describe.each(dirs)('%s', dir => { + test.each(listFixtures(revision, dir))('%s satisfies the generated spec validator', file => { + let fixture = JSON.parse(readFileSync(join(FIXTURES_ROOT, revision, dir, file), 'utf8')) as Record; + // The hand-built 2025 mini-corpus stores BARE message shapes (the + // SDK parse surface); the spec defs model the full JSON-RPC wire + // message. Supply the neutral envelope members the def requires + // and the fixture deliberately omits — the PAYLOAD is what the + // fixtures pin, and it crosses to the twin verbatim. + const required = twin.requiredOf(dir); + if (typeof fixture === 'object' && fixture !== null && !('jsonrpc' in fixture)) { + if (required.includes('jsonrpc')) fixture = { jsonrpc: '2.0', ...fixture }; + if (required.includes('id') && !('id' in fixture)) fixture = { id: 'twin-probe', ...fixture }; + } + const validate = twin.validatorFor(dir); + expect(validate, `no compiled validator for ${dir}`).toBeDefined(); + const valid = validate!(fixture); + expect( + valid, + `'${dir}/${file}' rejected by the generated ${revision} validator:\n${JSON.stringify(validate!.errors, null, 2)}` + ).toBe(true); + }); + }); +}); 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); + }); +}); diff --git a/packages/middleware/express/src/express.ts b/packages/middleware/express/src/express.ts index 252502952b..70d3881d0b 100644 --- a/packages/middleware/express/src/express.ts +++ b/packages/middleware/express/src/express.ts @@ -2,6 +2,7 @@ import type { Express } from 'express'; import express from 'express'; import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js'; +import { localhostOriginValidation, originValidation } from './middleware/originValidation.js'; /** * Options for creating an MCP Express application. @@ -23,6 +24,18 @@ export interface CreateMcpExpressAppOptions { */ allowedHosts?: string[]; + /** + * List of allowed origin hostnames for Origin header validation. + * If provided, Origin validation will be applied using this list (port-agnostic, + * hostnames only — the same convention as `allowedHosts`). + * + * When omitted, Origin validation is automatically enabled for localhost-class + * binds (the same condition as host validation): requests without an `Origin` + * header pass, while a present `Origin` whose hostname is not localhost-class + * is rejected with `403`. + */ + allowedOrigins?: string[]; + /** * Controls the maximum request body size for the JSON body parser. * Passed directly to Express's `express.json({ limit })` option. @@ -60,7 +73,7 @@ export interface CreateMcpExpressAppOptions { * ``` */ export function createMcpExpressApp(options: CreateMcpExpressAppOptions = {}): Express { - const { host = '127.0.0.1', allowedHosts, jsonLimit } = options; + const { host = '127.0.0.1', allowedHosts, allowedOrigins, jsonLimit } = options; const app = express(); app.use(express.json(jsonLimit ? { limit: jsonLimit } : undefined)); @@ -84,5 +97,14 @@ export function createMcpExpressApp(options: CreateMcpExpressAppOptions = {}): E } } + // Origin validation follows the same arming ladder as host validation: + // an explicit allowlist wins; otherwise localhost-class binds are protected + // by default. Requests without an Origin header always pass. + if (allowedOrigins) { + app.use(originValidation(allowedOrigins)); + } else if (['127.0.0.1', 'localhost', '::1'].includes(host)) { + app.use(localhostOriginValidation()); + } + return app; } diff --git a/packages/middleware/express/src/index.ts b/packages/middleware/express/src/index.ts index d2742ce782..941354d4ab 100644 --- a/packages/middleware/express/src/index.ts +++ b/packages/middleware/express/src/index.ts @@ -1,5 +1,6 @@ export * from './express.js'; export * from './middleware/hostHeaderValidation.js'; +export * from './middleware/originValidation.js'; // OAuth Resource-Server glue: bearer-token middleware + PRM/AS metadata router. export type { BearerAuthMiddlewareOptions } from './auth/bearerAuth.js'; diff --git a/packages/middleware/express/src/middleware/originValidation.ts b/packages/middleware/express/src/middleware/originValidation.ts new file mode 100644 index 0000000000..d92513ae6c --- /dev/null +++ b/packages/middleware/express/src/middleware/originValidation.ts @@ -0,0 +1,52 @@ +import { localhostAllowedOrigins, validateOriginHeader } from '@modelcontextprotocol/server'; +import type { NextFunction, Request, RequestHandler, Response } from 'express'; + +/** + * Express middleware for Origin header validation. + * Validates the `Origin` header hostname (port-agnostic) against an allowed list. + * + * Browsers attach an `Origin` header to cross-origin requests; validating it — + * alongside Host header validation — protects localhost and development servers + * against DNS rebinding and cross-site request forgery. Requests without an + * `Origin` header pass (non-browser MCP clients do not send one); a present + * value that is not allowed, or that cannot be parsed, is rejected with `403`. + * + * @param allowedOriginHostnames - List of allowed origin hostnames (without scheme or port). + * For IPv6, provide the address with brackets (e.g., `[::1]`). + * @returns Express middleware function + * + * @example + * ```ts + * app.use(originValidation(['localhost', '127.0.0.1', '[::1]'])); + * ``` + */ +export function originValidation(allowedOriginHostnames: string[]): RequestHandler { + return (req: Request, res: Response, next: NextFunction) => { + const result = validateOriginHeader(req.headers.origin, allowedOriginHostnames); + if (!result.ok) { + res.status(403).json({ + jsonrpc: '2.0', + error: { + code: -32_000, + message: result.message + }, + id: null + }); + return; + } + next(); + }; +} + +/** + * Convenience middleware for localhost Origin validation. + * Allows only origins whose hostname is `localhost`, `127.0.0.1`, or `[::1]` (IPv6 localhost). + * + * @example + * ```ts + * app.use(localhostOriginValidation()); + * ``` + */ +export function localhostOriginValidation(): RequestHandler { + return originValidation(localhostAllowedOrigins()); +} diff --git a/packages/middleware/express/test/originValidation.test.ts b/packages/middleware/express/test/originValidation.test.ts new file mode 100644 index 0000000000..5184adf0aa --- /dev/null +++ b/packages/middleware/express/test/originValidation.test.ts @@ -0,0 +1,171 @@ +import type { NextFunction, Request, Response } from 'express'; +import supertest from 'supertest'; +import { vi } from 'vitest'; + +import { createMcpExpressApp } from '../src/express.js'; +import { localhostOriginValidation, originValidation } from '../src/middleware/originValidation.js'; + +// Helper to create mock Express request/response/next +function createMockReqResNext(origin?: string) { + const req = { + headers: { + origin + } + } as Request; + + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis() + } as unknown as Response; + + const next = vi.fn() as NextFunction; + + return { req, res, next }; +} + +describe('@modelcontextprotocol/express origin validation', () => { + describe('originValidation', () => { + test('should block a disallowed Origin header', () => { + const middleware = originValidation(['localhost']); + const { req, res, next } = createMockReqResNext('http://evil.example.com'); + + middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ + code: -32_000 + }), + id: null + }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + test('should allow an allowed Origin header (port-agnostic)', () => { + const middleware = originValidation(['localhost']); + const { req, res, next } = createMockReqResNext('http://localhost:3000'); + + middleware(req, res, next); + + expect(res.status).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); + + test('should allow requests without an Origin header (non-browser clients)', () => { + const middleware = originValidation(['localhost']); + const { req, res, next } = createMockReqResNext(undefined); + + middleware(req, res, next); + + expect(res.status).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); + + test('should deny on failure: malformed and null origins are rejected, never passed through', () => { + const middleware = originValidation(['localhost']); + for (const malformed of ['null', 'not a url']) { + const { req, res, next } = createMockReqResNext(malformed); + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + } + }); + + test('localhostOriginValidation allows the localhost family only', () => { + const middleware = localhostOriginValidation(); + + const allowed = createMockReqResNext('http://127.0.0.1:8080'); + middleware(allowed.req, allowed.res, allowed.next); + expect(allowed.next).toHaveBeenCalled(); + + const blocked = createMockReqResNext('http://localhost.evil.example.com'); + middleware(blocked.req, blocked.res, blocked.next); + expect(blocked.res.status).toHaveBeenCalledWith(403); + expect(blocked.next).not.toHaveBeenCalled(); + }); + }); + + describe('createMcpExpressApp origin arming', () => { + test('builds an app with default localhost origin protection', () => { + const app = createMcpExpressApp(); + expect(app).toBeDefined(); + expect(typeof app.use).toBe('function'); + }); + + test('arms localhost origin validation by default (requests are actually filtered)', async () => { + const app = createMcpExpressApp(); + app.get('/health', (_req, res) => { + res.json({ ok: true }); + }); + + const blocked = await supertest(app).get('/health').set('Origin', 'http://evil.example.com'); + expect(blocked.status).toBe(403); + expect(blocked.body).toEqual( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ code: -32_000 }), + id: null + }) + ); + + const allowed = await supertest(app).get('/health').set('Origin', 'http://localhost:5173'); + expect(allowed.status).toBe(200); + + const noOrigin = await supertest(app).get('/health'); + expect(noOrigin.status).toBe(200); + }); + + test('an explicit allowedOrigins list replaces the default allowlist (validation stays armed)', async () => { + const app = createMcpExpressApp({ + host: '0.0.0.0', + allowedHosts: ['127.0.0.1', 'myapp.local'], + allowedOrigins: ['myapp.local'] + }); + app.get('/health', (_req, res) => { + res.json({ ok: true }); + }); + + const good = await supertest(app).get('/health').set('Origin', 'https://myapp.local'); + expect(good.status).toBe(200); + + const bad = await supertest(app).get('/health').set('Origin', 'http://localhost:5173'); + expect(bad.status).toBe(403); + }); + + test('applies no origin validation for 0.0.0.0 without allowedOrigins', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const app = createMcpExpressApp({ host: '0.0.0.0' }); + app.get('/health', (_req, res) => { + res.json({ ok: true }); + }); + + const res = await supertest(app).get('/health').set('Origin', 'http://evil.example.com'); + expect(res.status).toBe(200); + warn.mockRestore(); + }); + + test('accepts an allowedOrigins override without warnings', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'], allowedOrigins: ['myapp.local'] }); + expect(app).toBeDefined(); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + test('keeps the existing 0.0.0.0 warning untouched when no allowlists are provided', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + createMcpExpressApp({ host: '0.0.0.0' }); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Warning: Server is binding to 0.0.0.0 without DNS rebinding protection') + ); + + warn.mockRestore(); + }); + }); +}); diff --git a/packages/middleware/fastify/src/fastify.ts b/packages/middleware/fastify/src/fastify.ts index 33c03dc808..cb5877c9a6 100644 --- a/packages/middleware/fastify/src/fastify.ts +++ b/packages/middleware/fastify/src/fastify.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify'; import Fastify from 'fastify'; import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js'; +import { localhostOriginValidation, originValidation } from './middleware/originValidation.js'; /** * Options for creating an MCP Fastify application. @@ -22,6 +23,18 @@ export interface CreateMcpFastifyAppOptions { * to restrict which hostnames are allowed. */ allowedHosts?: string[]; + + /** + * List of allowed origin hostnames for Origin header validation. + * If provided, Origin validation will be applied using this list (port-agnostic, + * hostnames only — the same convention as `allowedHosts`). + * + * When omitted, Origin validation is automatically enabled for localhost-class + * binds (the same condition as host validation): requests without an `Origin` + * header pass, while a present `Origin` whose hostname is not localhost-class + * is rejected with `403`. + */ + allowedOrigins?: string[]; } /** @@ -54,7 +67,7 @@ export interface CreateMcpFastifyAppOptions { * ``` */ export function createMcpFastifyApp(options: CreateMcpFastifyAppOptions = {}): FastifyInstance { - const { host = '127.0.0.1', allowedHosts } = options; + const { host = '127.0.0.1', allowedHosts, allowedOrigins } = options; const app = Fastify(); @@ -78,5 +91,14 @@ export function createMcpFastifyApp(options: CreateMcpFastifyAppOptions = {}): F } } + // Origin validation follows the same arming ladder as host validation: + // an explicit allowlist wins; otherwise localhost-class binds are protected + // by default. Requests without an Origin header always pass. + if (allowedOrigins) { + app.addHook('onRequest', originValidation(allowedOrigins)); + } else if (['127.0.0.1', 'localhost', '::1'].includes(host)) { + app.addHook('onRequest', localhostOriginValidation()); + } + return app; } diff --git a/packages/middleware/fastify/src/index.ts b/packages/middleware/fastify/src/index.ts index 5c852617bb..61748e59a1 100644 --- a/packages/middleware/fastify/src/index.ts +++ b/packages/middleware/fastify/src/index.ts @@ -1,2 +1,3 @@ export * from './fastify.js'; export * from './middleware/hostHeaderValidation.js'; +export * from './middleware/originValidation.js'; diff --git a/packages/middleware/fastify/src/middleware/originValidation.ts b/packages/middleware/fastify/src/middleware/originValidation.ts new file mode 100644 index 0000000000..aad855885c --- /dev/null +++ b/packages/middleware/fastify/src/middleware/originValidation.ts @@ -0,0 +1,50 @@ +import { localhostAllowedOrigins, validateOriginHeader } from '@modelcontextprotocol/server'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + +/** + * Fastify onRequest hook for Origin header validation. + * Validates the `Origin` header hostname (port-agnostic) against an allowed list. + * + * Browsers attach an `Origin` header to cross-origin requests; validating it — + * alongside Host header validation — protects localhost and development servers + * against DNS rebinding and cross-site request forgery. Requests without an + * `Origin` header pass (non-browser MCP clients do not send one); a present + * value that is not allowed, or that cannot be parsed, is rejected with `403`. + * + * @param allowedOriginHostnames - List of allowed origin hostnames (without scheme or port). + * For IPv6, provide the address with brackets (e.g., `[::1]`). + * @returns Fastify onRequest hook handler + * + * @example + * ```ts + * app.addHook('onRequest', originValidation(['localhost', '127.0.0.1', '[::1]'])); + * ``` + */ +export function originValidation(allowedOriginHostnames: string[]) { + return async (request: FastifyRequest, reply: FastifyReply): Promise => { + const result = validateOriginHeader(request.headers.origin, allowedOriginHostnames); + if (!result.ok) { + await reply.code(403).send({ + jsonrpc: '2.0', + error: { + code: -32_000, + message: result.message + }, + id: null + }); + } + }; +} + +/** + * Convenience hook for localhost Origin validation. + * Allows only origins whose hostname is `localhost`, `127.0.0.1`, or `[::1]` (IPv6 localhost). + * + * @example + * ```ts + * app.addHook('onRequest', localhostOriginValidation()); + * ``` + */ +export function localhostOriginValidation() { + return originValidation(localhostAllowedOrigins()); +} diff --git a/packages/middleware/fastify/test/originValidation.test.ts b/packages/middleware/fastify/test/originValidation.test.ts new file mode 100644 index 0000000000..14dfc42beb --- /dev/null +++ b/packages/middleware/fastify/test/originValidation.test.ts @@ -0,0 +1,116 @@ +import Fastify from 'fastify'; + +import { createMcpFastifyApp } from '../src/fastify.js'; +import { localhostOriginValidation, originValidation } from '../src/middleware/originValidation.js'; + +describe('@modelcontextprotocol/fastify origin validation', () => { + describe('originValidation', () => { + test('should block a disallowed Origin header', async () => { + const app = Fastify(); + app.addHook('onRequest', originValidation(['localhost'])); + app.get('/health', async () => ({ ok: true })); + + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'localhost:3000', origin: 'http://evil.example.com' } + }); + + expect(res.statusCode).toBe(403); + expect(res.json()).toEqual( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ + code: -32_000 + }), + id: null + }) + ); + }); + + test('should allow an allowed Origin header and requests without an Origin header', async () => { + const app = Fastify(); + app.addHook('onRequest', localhostOriginValidation()); + app.get('/health', async () => 'ok'); + + const allowed = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'localhost:3000', origin: 'http://localhost:5173' } + }); + expect(allowed.statusCode).toBe(200); + + const noOrigin = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'localhost:3000' } + }); + expect(noOrigin.statusCode).toBe(200); + }); + + test('should deny malformed Origin values (deny on failure)', async () => { + const app = Fastify(); + app.addHook('onRequest', localhostOriginValidation()); + app.get('/health', async () => 'ok'); + + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'localhost:3000', origin: 'null' } + }); + expect(res.statusCode).toBe(403); + }); + }); + + describe('createMcpFastifyApp origin arming', () => { + test('arms localhost origin validation by default', async () => { + const app = createMcpFastifyApp(); + app.get('/health', async () => 'ok'); + + const bad = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'localhost:3000', origin: 'http://evil.example.com' } + }); + expect(bad.statusCode).toBe(403); + + const good = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'localhost:3000', origin: 'http://localhost:5173' } + }); + expect(good.statusCode).toBe(200); + }); + + test('uses allowedOrigins when provided', async () => { + const app = createMcpFastifyApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'], allowedOrigins: ['myapp.local'] }); + app.get('/health', async () => 'ok'); + + const good = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'myapp.local:3000', origin: 'https://myapp.local' } + }); + expect(good.statusCode).toBe(200); + + const bad = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'myapp.local:3000', origin: 'http://evil.example.com' } + }); + expect(bad.statusCode).toBe(403); + }); + + test('applies no origin validation for 0.0.0.0 without allowedOrigins', async () => { + const app = createMcpFastifyApp({ host: '0.0.0.0' }); + app.get('/health', async () => 'ok'); + + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'whatever.example.com', origin: 'http://evil.example.com' } + }); + expect(res.statusCode).toBe(200); + }); + }); +}); diff --git a/packages/middleware/hono/src/hono.ts b/packages/middleware/hono/src/hono.ts index eda3e5d8fa..7d5405ce99 100644 --- a/packages/middleware/hono/src/hono.ts +++ b/packages/middleware/hono/src/hono.ts @@ -2,6 +2,7 @@ import type { Context } from 'hono'; import { Hono } from 'hono'; import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js'; +import { localhostOriginValidation, originValidation } from './middleware/originValidation.js'; /** * Options for creating an MCP Hono application. @@ -22,6 +23,18 @@ export interface CreateMcpHonoAppOptions { * to restrict which hostnames are allowed. */ allowedHosts?: string[]; + + /** + * List of allowed origin hostnames for Origin header validation. + * If provided, Origin validation will be applied using this list (port-agnostic, + * hostnames only — the same convention as `allowedHosts`). + * + * When omitted, Origin validation is automatically enabled for localhost-class + * binds (the same condition as host validation): requests without an `Origin` + * header pass, while a present `Origin` whose hostname is not localhost-class + * is rejected with `403`. + */ + allowedOrigins?: string[]; } /** @@ -39,7 +52,7 @@ export interface CreateMcpHonoAppOptions { * @returns A configured Hono application */ export function createMcpHonoApp(options: CreateMcpHonoAppOptions = {}): Hono { - const { host = '127.0.0.1', allowedHosts } = options; + const { host = '127.0.0.1', allowedHosts, allowedOrigins } = options; const app = new Hono(); @@ -86,5 +99,14 @@ export function createMcpHonoApp(options: CreateMcpHonoAppOptions = {}): Hono { } } + // Origin validation follows the same arming ladder as host validation: + // an explicit allowlist wins; otherwise localhost-class binds are protected + // by default. Requests without an Origin header always pass. + if (allowedOrigins) { + app.use('*', originValidation(allowedOrigins)); + } else if (['127.0.0.1', 'localhost', '::1'].includes(host)) { + app.use('*', localhostOriginValidation()); + } + return app; } diff --git a/packages/middleware/hono/src/index.ts b/packages/middleware/hono/src/index.ts index a8c65a2e98..177b54d5b3 100644 --- a/packages/middleware/hono/src/index.ts +++ b/packages/middleware/hono/src/index.ts @@ -1,2 +1,3 @@ export * from './hono.js'; export * from './middleware/hostHeaderValidation.js'; +export * from './middleware/originValidation.js'; diff --git a/packages/middleware/hono/src/middleware/originValidation.ts b/packages/middleware/hono/src/middleware/originValidation.ts new file mode 100644 index 0000000000..f75076c2be --- /dev/null +++ b/packages/middleware/hono/src/middleware/originValidation.ts @@ -0,0 +1,38 @@ +import { localhostAllowedOrigins, validateOriginHeader } from '@modelcontextprotocol/server'; +import type { MiddlewareHandler } from 'hono'; + +/** + * Hono middleware for Origin header validation. + * Validates the `Origin` header hostname (port-agnostic) against an allowed list. + * + * Requests without an `Origin` header pass (non-browser MCP clients do not send + * one); a present value that is not allowed, or that cannot be parsed, is + * rejected with `403`. + */ +export function originValidation(allowedOriginHostnames: string[]): MiddlewareHandler { + return async (c, next) => { + const result = validateOriginHeader(c.req.header('origin'), allowedOriginHostnames); + if (!result.ok) { + return c.json( + { + jsonrpc: '2.0', + error: { + code: -32_000, + message: result.message + }, + id: null + }, + 403 + ); + } + return await next(); + }; +} + +/** + * Convenience middleware for localhost Origin validation. + * Allows only origins whose hostname is `localhost`, `127.0.0.1`, or `[::1]` (IPv6 localhost). + */ +export function localhostOriginValidation(): MiddlewareHandler { + return originValidation(localhostAllowedOrigins()); +} diff --git a/packages/middleware/hono/test/originValidation.test.ts b/packages/middleware/hono/test/originValidation.test.ts new file mode 100644 index 0000000000..c395921d39 --- /dev/null +++ b/packages/middleware/hono/test/originValidation.test.ts @@ -0,0 +1,91 @@ +import { Hono } from 'hono'; +import { vi } from 'vitest'; + +import { createMcpHonoApp } from '../src/hono.js'; +import { localhostOriginValidation, originValidation } from '../src/middleware/originValidation.js'; + +describe('@modelcontextprotocol/hono origin validation', () => { + test('originValidation blocks a disallowed Origin and allows an allowed Origin', async () => { + const app = new Hono(); + app.use('*', originValidation(['localhost'])); + app.get('/health', c => c.text('ok')); + + const bad = await app.request('http://localhost/health', { + headers: { Host: 'localhost:3000', Origin: 'http://evil.example.com' } + }); + expect(bad.status).toBe(403); + expect(await bad.json()).toEqual( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ + code: -32_000 + }), + id: null + }) + ); + + const good = await app.request('http://localhost/health', { headers: { Host: 'localhost:3000', Origin: 'http://localhost:3000' } }); + expect(good.status).toBe(200); + expect(await good.text()).toBe('ok'); + }); + + test('originValidation allows requests without an Origin header and denies malformed origins', async () => { + const app = new Hono(); + app.use('*', localhostOriginValidation()); + app.get('/health', c => c.text('ok')); + + const noOrigin = await app.request('http://localhost/health', { headers: { Host: 'localhost:3000' } }); + expect(noOrigin.status).toBe(200); + + const malformed = await app.request('http://localhost/health', { headers: { Host: 'localhost:3000', Origin: 'null' } }); + expect(malformed.status).toBe(403); + }); + + test('createMcpHonoApp arms localhost origin validation by default', async () => { + const app = createMcpHonoApp(); + app.get('/health', c => c.text('ok')); + + const bad = await app.request('http://localhost/health', { + headers: { Host: 'localhost:3000', Origin: 'http://evil.example.com' } + }); + expect(bad.status).toBe(403); + + const goodOrigin = await app.request('http://localhost/health', { + headers: { Host: 'localhost:3000', Origin: 'http://localhost:5173' } + }); + expect(goodOrigin.status).toBe(200); + + const noOrigin = await app.request('http://localhost/health', { headers: { Host: 'localhost:3000' } }); + expect(noOrigin.status).toBe(200); + }); + + test('createMcpHonoApp uses allowedOrigins when provided', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const app = createMcpHonoApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'], allowedOrigins: ['myapp.local'] }); + warn.mockRestore(); + app.get('/health', c => c.text('ok')); + + const good = await app.request('http://localhost/health', { + headers: { Host: 'myapp.local:3000', Origin: 'https://myapp.local' } + }); + expect(good.status).toBe(200); + + const bad = await app.request('http://localhost/health', { + headers: { Host: 'myapp.local:3000', Origin: 'http://evil.example.com' } + }); + expect(bad.status).toBe(403); + }); + + test('createMcpHonoApp applies no origin validation for 0.0.0.0 without allowedOrigins (existing warning preserved)', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const app = createMcpHonoApp({ host: '0.0.0.0' }); + expect(warn).toHaveBeenCalledTimes(1); + warn.mockRestore(); + app.get('/health', c => c.text('ok')); + + const anyOrigin = await app.request('http://localhost/health', { + headers: { Host: 'whatever.example.com', Origin: 'http://evil.example.com' } + }); + expect(anyOrigin.status).toBe(200); + }); +}); diff --git a/packages/middleware/node/src/index.ts b/packages/middleware/node/src/index.ts index 2e0d3c9950..8426de0757 100644 --- a/packages/middleware/node/src/index.ts +++ b/packages/middleware/node/src/index.ts @@ -1 +1,3 @@ +export * from './middleware/hostHeaderValidation.js'; +export * from './middleware/originValidation.js'; export * from './streamableHttp.js'; diff --git a/packages/middleware/node/src/middleware/hostHeaderValidation.ts b/packages/middleware/node/src/middleware/hostHeaderValidation.ts new file mode 100644 index 0000000000..4630c27669 --- /dev/null +++ b/packages/middleware/node/src/middleware/hostHeaderValidation.ts @@ -0,0 +1,53 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { localhostAllowedHostnames, validateHostHeader } from '@modelcontextprotocol/server'; + +/** + * Node.js request guard for DNS rebinding protection. + * Validates the `Host` header hostname (port-agnostic) against an allowed list. + * + * Unlike the framework adapters, plain `node:http` has no middleware chain, so + * the guard returns whether the request may proceed: when it returns `false` + * it has already answered the request with a `403` JSON-RPC error and the + * caller must not handle it further. + * + * @param allowedHostnames - List of allowed hostnames (without ports). + * For IPv6, provide the address with brackets (e.g., `[::1]`). + * + * @example + * ```ts + * const validateHost = hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']); + * http.createServer((req, res) => { + * if (!validateHost(req, res)) return; + * void transport.handleRequest(req, res); + * }); + * ``` + */ +export function hostHeaderValidation(allowedHostnames: string[]): (req: IncomingMessage, res: ServerResponse) => boolean { + return (req, res) => { + const result = validateHostHeader(req.headers.host, allowedHostnames); + if (result.ok) { + return true; + } + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32_000, + message: result.message + }, + id: null + }) + ); + return false; + }; +} + +/** + * Convenience guard for localhost DNS rebinding protection. + * Allows only `localhost`, `127.0.0.1`, and `[::1]` (IPv6 localhost) hostnames. + */ +export function localhostHostValidation(): (req: IncomingMessage, res: ServerResponse) => boolean { + return hostHeaderValidation(localhostAllowedHostnames()); +} diff --git a/packages/middleware/node/src/middleware/originValidation.ts b/packages/middleware/node/src/middleware/originValidation.ts new file mode 100644 index 0000000000..a38fc05144 --- /dev/null +++ b/packages/middleware/node/src/middleware/originValidation.ts @@ -0,0 +1,54 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { localhostAllowedOrigins, validateOriginHeader } from '@modelcontextprotocol/server'; + +/** + * Node.js request guard for Origin header validation. + * Validates the `Origin` header hostname (port-agnostic) against an allowed list. + * + * Requests without an `Origin` header pass (non-browser MCP clients do not send + * one); a present value that is not allowed, or that cannot be parsed, is + * rejected with `403`. The guard returns whether the request may proceed: when + * it returns `false` it has already answered the request and the caller must + * not handle it further. + * + * @param allowedOriginHostnames - List of allowed origin hostnames (without scheme or port). + * For IPv6, provide the address with brackets (e.g., `[::1]`). + * + * @example + * ```ts + * const validateOrigin = originValidation(['localhost', '127.0.0.1', '[::1]']); + * http.createServer((req, res) => { + * if (!validateOrigin(req, res)) return; + * void transport.handleRequest(req, res); + * }); + * ``` + */ +export function originValidation(allowedOriginHostnames: string[]): (req: IncomingMessage, res: ServerResponse) => boolean { + return (req, res) => { + const result = validateOriginHeader(req.headers.origin, allowedOriginHostnames); + if (result.ok) { + return true; + } + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32_000, + message: result.message + }, + id: null + }) + ); + return false; + }; +} + +/** + * Convenience guard for localhost Origin validation. + * Allows only origins whose hostname is `localhost`, `127.0.0.1`, or `[::1]` (IPv6 localhost). + */ +export function localhostOriginValidation(): (req: IncomingMessage, res: ServerResponse) => boolean { + return originValidation(localhostAllowedOrigins()); +} diff --git a/packages/middleware/node/src/streamableHttp.ts b/packages/middleware/node/src/streamableHttp.ts index 68a0c224f0..579db2f2fe 100644 --- a/packages/middleware/node/src/streamableHttp.ts +++ b/packages/middleware/node/src/streamableHttp.ts @@ -152,6 +152,17 @@ export class NodeStreamableHTTPServerTransport implements Transport { return this._webStandardTransport.send(message, options); } + /** + * Forwards the supported protocol versions to the wrapped Web Standard + * transport for `MCP-Protocol-Version` header validation. Called by the + * protocol layer during connect; without this delegation a server's + * `supportedProtocolVersions` option never reached the Node adapter's + * header validation. + */ + setSupportedProtocolVersions(versions: string[]): void { + this._webStandardTransport.setSupportedProtocolVersions(versions); + } + /** * Handles an incoming HTTP request, whether `GET` or `POST`. * diff --git a/packages/middleware/node/test/validation.test.ts b/packages/middleware/node/test/validation.test.ts new file mode 100644 index 0000000000..01e98108e0 --- /dev/null +++ b/packages/middleware/node/test/validation.test.ts @@ -0,0 +1,79 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { vi } from 'vitest'; + +import { hostHeaderValidation, localhostHostValidation } from '../src/middleware/hostHeaderValidation.js'; +import { localhostOriginValidation, originValidation } from '../src/middleware/originValidation.js'; + +function fakeReqRes(headers: Record) { + const req = { headers } as unknown as IncomingMessage; + const writeHead = vi.fn().mockReturnThis(); + const end = vi.fn().mockReturnThis(); + const res = { writeHead, end } as unknown as ServerResponse; + return { req, res, writeHead, end }; +} + +function sentBody(end: ReturnType): unknown { + const payload = end.mock.calls[0]?.[0] as string | undefined; + return payload === undefined ? undefined : JSON.parse(payload); +} + +describe('@modelcontextprotocol/node validation guards', () => { + describe('hostHeaderValidation', () => { + test('blocks a disallowed Host header with a 403 JSON-RPC error and reports the request as handled', () => { + const guard = hostHeaderValidation(['localhost']); + const { req, res, writeHead, end } = fakeReqRes({ host: 'evil.example.com:3000' }); + + expect(guard(req, res)).toBe(false); + expect(writeHead).toHaveBeenCalledWith(403, { 'Content-Type': 'application/json' }); + expect(sentBody(end)).toEqual( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ code: -32_000 }), + id: null + }) + ); + }); + + test('allows an allowed Host header (port-agnostic)', () => { + const guard = localhostHostValidation(); + const { req, res, writeHead } = fakeReqRes({ host: '127.0.0.1:8080' }); + + expect(guard(req, res)).toBe(true); + expect(writeHead).not.toHaveBeenCalled(); + }); + }); + + describe('originValidation', () => { + test('blocks a disallowed Origin header with a 403 JSON-RPC error', () => { + const guard = originValidation(['localhost']); + const { req, res, writeHead, end } = fakeReqRes({ host: 'localhost:3000', origin: 'http://evil.example.com' }); + + expect(guard(req, res)).toBe(false); + expect(writeHead).toHaveBeenCalledWith(403, { 'Content-Type': 'application/json' }); + expect(sentBody(end)).toEqual( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ code: -32_000 }), + id: null + }) + ); + }); + + test('allows an allowed Origin and requests without an Origin header', () => { + const guard = localhostOriginValidation(); + + const allowed = fakeReqRes({ host: 'localhost:3000', origin: 'http://localhost:5173' }); + expect(guard(allowed.req, allowed.res)).toBe(true); + + const absent = fakeReqRes({ host: 'localhost:3000' }); + expect(guard(absent.req, absent.res)).toBe(true); + }); + + test('denies malformed Origin values (deny on failure)', () => { + const guard = localhostOriginValidation(); + const { req, res } = fakeReqRes({ host: 'localhost:3000', origin: 'null' }); + expect(guard(req, res)).toBe(false); + }); + }); +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index c33d394c8b..76244d12b3 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -8,6 +8,17 @@ export type { CompletableSchema, CompleteCallback } from './server/completable.js'; export { completable, isCompletable } from './server/completable.js'; +export type { + CreateMcpHandlerOptions, + LegacyHttpHandler, + McpHandlerRequestOptions, + McpHttpHandler, + McpRequestContext, + McpServerFactory, + NodeIncomingMessageLike, + NodeServerResponseLike +} from './server/createMcpHandler.js'; +export { createMcpHandler, legacyStatelessFallback } from './server/createMcpHandler.js'; export type { AnyToolHandler, BaseToolCallback, @@ -26,6 +37,10 @@ export type { export { McpServer, ResourceTemplate } from './server/mcp.js'; export type { HostHeaderValidationResult } from './server/middleware/hostHeaderValidation.js'; export { hostHeaderValidationResponse, localhostAllowedHostnames, validateHostHeader } from './server/middleware/hostHeaderValidation.js'; +export type { OriginValidationResult } from './server/middleware/originValidation.js'; +export { localhostAllowedOrigins, originValidationResponse, validateOriginHeader } from './server/middleware/originValidation.js'; +export type { PerRequestHTTPServerTransportOptions, PerRequestMessageExtra, PerRequestResponseMode } from './server/perRequestTransport.js'; +export { PerRequestHTTPServerTransport } from './server/perRequestTransport.js'; export type { ServerOptions } from './server/server.js'; export { Server } from './server/server.js'; // StdioServerTransport is exported from the './stdio' subpath — server stdio has only type-level Node @@ -43,5 +58,22 @@ export { WebStandardStreamableHTTPServerTransport } from './server/streamableHtt // runtime-aware wrapper (shadows core/public's fromJsonSchema with optional validator) export { fromJsonSchema } from './fromJsonSchema.js'; +// Inbound HTTP request classification (dual-era serving): the body-primary era +// predicate used by createMcpHandler, exported for hand-wired compositions. +export type { + InboundClassificationOutcome, + InboundHttpRequest, + InboundLadderRejection, + InboundLegacyRoute, + InboundLegacyRouteReason, + InboundModernRoute, + InboundValidationRung +} 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/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts new file mode 100644 index 0000000000..00f35ddeac --- /dev/null +++ b/packages/server/src/server/createMcpHandler.ts @@ -0,0 +1,788 @@ +/** + * `createMcpHandler` — the HTTP entry point for serving the 2026-07-28 protocol + * revision, with 2025-era serving available as an opt-in slot. + * + * The entry classifies every inbound HTTP request exactly once (body-primary, + * via {@linkcode classifyInboundRequest}) and routes it: + * + * - Requests carrying the per-request `_meta` envelope are served on the modern + * path: a fresh server instance from the consumer's factory, marked as + * serving the claimed revision, connected to a single-exchange per-request + * transport. + * - Requests without an envelope claim (including `initialize`, GET/DELETE + * session operations, and 2025-era notification POSTs) are legacy traffic. + * When the `legacy` slot is configured they are handed to it untouched; when + * it is not, the endpoint is modern-only strict and answers the documented + * rejection cells. There is no silent 2025 serving without the slot. + * + * The entry performs no Origin/Host validation (mount the origin/host + * validation middleware in front of it) and no token verification — `authInfo` + * is pass-through from the caller and is never derived from request headers. + */ +import type { + AuthInfo, + ClientCapabilities, + Implementation, + InboundLadderRejection, + InboundLegacyRoute, + InboundModernRoute, + JSONRPCNotification, + JSONRPCRequest, + RequestId +} from '@modelcontextprotocol/core'; +import { + classifyInboundRequest, + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + httpStatusForErrorCode, + missingClientCapabilities, + MissingRequiredClientCapabilityError, + modernOnlyStrictRejection, + requestMetaOf, + requiredClientCapabilitiesForRequest, + SdkError, + SdkErrorCode, + setNegotiatedProtocolVersion, + SUPPORTED_MODERN_PROTOCOL_VERSIONS, + UnsupportedProtocolVersionError +} from '@modelcontextprotocol/core'; + +import { invoke } from './invoke.js'; +import { McpServer } from './mcp.js'; +import type { PerRequestResponseMode } from './perRequestTransport.js'; +import type { Server } from './server.js'; +import { installModernOnlyHandlers, seedClientIdentityFromEnvelope } from './server.js'; +import { WebStandardStreamableHTTPServerTransport } from './streamableHttp.js'; + +/* ------------------------------------------------------------------------ * + * Factory and handler types + * ------------------------------------------------------------------------ */ + +/** + * Per-request construction context handed to an {@linkcode McpServerFactory}. + * + * Zero-argument factories remain assignable unchanged; the context exists for + * factories that vary by principal or era (for example multi-tenant servers + * keyed off `authInfo`, or a factory that registers extra surface only for one + * era). + */ +export interface McpRequestContext { + /** + * The protocol era of the request the constructed instance will serve: + * `modern` for 2026-07-28 (per-request envelope) traffic, `legacy` for + * 2025-era traffic served through the `legacy: 'stateless'` slot. + */ + era: 'legacy' | 'modern'; + /** Validated authentication information passed by the caller of the handler face (pass-through). */ + authInfo?: AuthInfo; + /** The original HTTP request being served, when available. */ + requestInfo?: Request; +} + +/** + * A factory producing a fresh {@linkcode McpServer} (or low-level + * {@linkcode Server}) instance for one request. The same factory backs both + * the modern path and the `legacy: 'stateless'` slot — define your tools, + * resources and prompts once and serve them to both eras. + */ +export type McpServerFactory = (ctx: McpRequestContext) => McpServer | Server | Promise; + +/** Caller-provided per-request inputs for {@linkcode McpHttpHandler.fetch} and legacy slot handlers. */ +export interface McpHandlerRequestOptions { + /** + * Validated authentication information for the request. Strictly + * pass-through: the handler never populates this from request headers and + * performs no token verification of its own. + */ + authInfo?: AuthInfo; + /** A pre-parsed JSON request body (e.g. `req.body` from `express.json()`). */ + parsedBody?: unknown; +} + +/** + * A fetch-shaped handler serving 2025-era traffic for the `legacy` slot: + * receives the original request untouched (plus the caller-provided + * pass-through options) and produces the HTTP response. + */ +export type LegacyHttpHandler = (request: Request, options?: McpHandlerRequestOptions) => Promise; + +/** Options for {@linkcode createMcpHandler}. */ +export interface CreateMcpHandlerOptions { + /** + * How 2025-era (non-envelope) traffic is served: + * + * - omitted — modern-only strict: legacy-classified requests are rejected + * with the unsupported-protocol-version error naming the endpoint's + * supported revisions (legacy-classified notifications are acknowledged + * with `202` and dropped). **There is no silent 2025 serving.** + * - `'stateless'` — serve legacy traffic with the per-request stateless + * idiom (a fresh instance from the same factory and a streamable HTTP + * transport constructed with only `sessionIdGenerator: undefined`). + * Equivalent to passing {@linkcode legacyStatelessFallback | legacyStatelessFallback(factory)}. + * - a handler — bring your own legacy serving (for example an existing + * sessionful streamable HTTP wiring); requests are handed to it + * byte-untouched and its lifecycle stays yours. + */ + legacy?: 'stateless' | LegacyHttpHandler; + /** Callback for out-of-band errors and rejected requests (reporting only; it never alters the response). */ + onerror?: (error: Error) => void; + /** + * Response shaping for modern (2026-07-28) request exchanges: + * + * - `'auto'` (default) — a single JSON body unless the handler emits a + * related message before its result, in which case the response upgrades + * to an SSE stream. + * - `'sse'` — always stream. + * - `'json'` — never stream. **Mid-call notifications (progress, logging, + * any related message emitted before the result) are dropped** — only the + * terminal result is delivered. Listen-class subscription streams are + * always served over SSE regardless of this setting. + */ + responseMode?: PerRequestResponseMode; +} + +/** + * Minimal duck-typed shape of a Node.js `IncomingMessage` accepted by + * {@linkcode McpHttpHandler.node}. Kept structural so the handler stays free of + * `node:` imports and bundles for non-Node runtimes. + */ +export interface NodeIncomingMessageLike extends AsyncIterable { + method?: string; + url?: string; + headers: Record; + /** Validated authentication info attached by upstream middleware (pass-through). */ + auth?: AuthInfo; +} + +/** Minimal duck-typed shape of a Node.js `ServerResponse` accepted by {@linkcode McpHttpHandler.node}. */ +export interface NodeServerResponseLike { + writeHead(statusCode: number, headers?: Record): unknown; + write(chunk: string | Uint8Array): unknown; + end(chunk?: string | Uint8Array): unknown; + on(event: string, listener: (...args: unknown[]) => void): unknown; +} + +/** + * The handler returned by {@linkcode createMcpHandler}. Both faces are + * arrow-assigned bound properties: they can be detached and passed around + * (`const { fetch } = handler`) without losing their binding. + */ +export interface McpHttpHandler { + /** Web-standard face: serve one HTTP request and resolve with the response. */ + fetch: (request: Request, options?: McpHandlerRequestOptions) => Promise; + /** + * Node face: serve one Node.js request/response pair. The third argument is + * an optional pre-parsed body (`req.body` from `express.json()`); a function + * third argument (Express's `next` when the handler is mounted as + * middleware) is ignored. + */ + node: (req: NodeIncomingMessageLike, res: NodeServerResponseLike, parsedBody?: unknown) => Promise; + /** + * Tears down the modern leg: aborts in-flight modern exchanges and closes + * their per-request instances. Legacy serving is unaffected — the + * `'stateless'` slot is per-request by construction, and a bring-your-own + * legacy handler's lifecycle stays with its owner. + */ + close: () => Promise; +} + +/* ------------------------------------------------------------------------ * + * Shared response helpers + * ------------------------------------------------------------------------ */ + +/** + * The JSON-RPC id to echo on an entry-built error response: the body's `id` + * when the body is a single JSON-RPC request whose id is a string or number, + * `null` otherwise. Error responses must carry the id of the request they + * correspond to whenever it could be read; `null` is reserved for the cases + * where no single request id is determinable — unparseable bodies, body-less + * methods, notifications, posted responses and batch arrays. + */ +function echoableRequestId(body: unknown): RequestId | null { + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + return null; + } + const { method, id } = body as { method?: unknown; id?: unknown }; + if (typeof method !== 'string') { + return null; + } + return typeof id === 'string' || typeof id === 'number' ? id : null; +} + +function jsonRpcErrorResponse(httpStatus: number, code: number, message: string, data?: unknown, id: RequestId | null = null): Response { + return Response.json( + { + jsonrpc: '2.0', + error: { code, message, ...(data !== undefined && { data }) }, + id + }, + { status: httpStatus } + ); +} + +function rejectionResponse(rejection: InboundLadderRejection, id: RequestId | null = null): Response { + return jsonRpcErrorResponse(rejection.httpStatus, rejection.code, rejection.message, rejection.data, id); +} + +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} + +/** + * Whether the given factory product has the (forthcoming) subscriptions feature + * configured. The subscriptions registry does not exist yet, so this currently + * always reports `false`; the subscriptions feature replaces this predicate + * when it lands, which arms the `responseMode: 'json'` startup warning below. + */ +function hasConfiguredSubscriptions(_product: McpServer | Server): boolean { + return false; +} + +function internalServerErrorResponse(id: RequestId | null = null): Response { + return jsonRpcErrorResponse(500, -32_603, 'Internal server error', undefined, id); +} + +/* ------------------------------------------------------------------------ * + * The canonical legacy slot value + * ------------------------------------------------------------------------ */ + +/** + * The canonical `legacy` slot value: per-request stateless serving of 2025-era + * traffic using the same factory as the modern path. + * + * Each POST is served by a fresh instance from the factory connected to a + * fresh streamable HTTP transport constructed with only + * `sessionIdGenerator: undefined` — the established stateless idiom, unchanged. + * Because serving is per-request and stateless, GET and DELETE (2025 session + * operations) are answered with `405` / `Method not allowed.`, exactly like the + * canonical stateless example. `createMcpHandler(factory, { legacy: 'stateless' })` + * is shorthand for passing `legacyStatelessFallback(factory)` here explicitly. + * + * The optional `onerror` callback receives factory and serving failures on + * this leg (reporting only — the response stays the 500 internal-error body). + * The entry passes its own `onerror` here when expanding `legacy: 'stateless'`, + * so legacy-leg failures are never silently swallowed. + */ +export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (error: Error) => void): LegacyHttpHandler { + return async (request, options) => { + if (request.method.toUpperCase() !== 'POST') { + return jsonRpcErrorResponse(405, -32_000, 'Method not allowed.'); + } + try { + const product = await factory({ + era: 'legacy', + ...(options?.authInfo !== undefined && { authInfo: options.authInfo }), + requestInfo: request + }); + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await product.connect(transport); + + const teardown = () => { + void transport.close().catch(() => {}); + void product.close().catch(() => {}); + }; + // Tear the per-request pair down when the client goes away before + // the exchange completes. + request.signal?.addEventListener('abort', teardown, { once: true }); + + const response = await transport.handleRequest(request, { + ...(options?.authInfo !== undefined && { authInfo: options.authInfo }), + ...(options?.parsedBody !== undefined && { parsedBody: options.parsedBody }) + }); + if (response.body === null || !(response.headers.get('content-type') ?? '').includes('text/event-stream')) { + // Non-streaming exchange (a buffered JSON body or a body-less + // ack): the response is complete, release the pair now. + teardown(); + return response; + } + // Streaming exchange: the legacy transport answers request-bearing + // POSTs over SSE, so the exchange is only over once the stream has + // been fully delivered. Wrap the body so the pair is torn down on + // completion, on a producer error, or when the consumer abandons + // the stream — the fetch-world analog of the canonical stateless + // example's close-on-response-end. + const reader = response.body.getReader(); + let toreDown = false; + const completeExchange = () => { + if (!toreDown) { + toreDown = true; + teardown(); + } + }; + const monitoredBody = new ReadableStream({ + pull: async controller => { + try { + const { done, value } = await reader.read(); + if (done) { + completeExchange(); + controller.close(); + return; + } + if (value !== undefined) { + controller.enqueue(value); + } + } catch (error) { + completeExchange(); + controller.error(error); + } + }, + cancel: reason => { + completeExchange(); + return reader.cancel(reason).catch(() => {}); + } + }); + return new Response(monitoredBody, { + status: response.status, + statusText: response.statusText, + headers: response.headers + }); + } catch (error) { + try { + onerror?.(toError(error)); + } catch { + // Reporting must never alter the response. + } + return internalServerErrorResponse(echoableRequestId(options?.parsedBody)); + } + }; +} + +/* ------------------------------------------------------------------------ * + * The entry + * ------------------------------------------------------------------------ */ + +/** + * Creates an HTTP handler that serves the 2026-07-28 protocol revision from a + * per-request server factory, with 2025-era serving available through the + * opt-in `legacy` slot. + * + * Mounting: `handler.fetch` is the web-standard face (Cloudflare Workers, + * Deno, Bun, Hono's `c.req.raw`); `handler.node(req, res, req.body)` is the + * Node face for Express/Fastify/plain `node:http`. When mounting bare on a + * fetch-native runtime, put Origin/Host validation in front of the handler — + * the entry itself is deliberately validation-free: + * + * ```ts + * import { hostHeaderValidationResponse, originValidationResponse, localhostAllowedHostnames, localhostAllowedOrigins } from '@modelcontextprotocol/server'; + * + * export default { + * async fetch(request: Request): Promise { + * const rejected = + * hostHeaderValidationResponse(request, localhostAllowedHostnames()) ?? + * originValidationResponse(request, localhostAllowedOrigins()); + * return rejected ?? handler.fetch(request); + * } + * }; + * ``` + * + * Use ONE factory for both legs: the same tools/resources/prompts definition + * backs the modern path and the `legacy: 'stateless'` slot, so the two eras + * can never drift apart. Power users who want to compose the routing + * themselves (for example to mount the modern path and an existing legacy + * deployment on different routes) can use the exported building blocks + * directly: {@linkcode classifyInboundRequest} for the era decision and + * `PerRequestHTTPServerTransport` for single-exchange serving. + * + * The entry performs no token verification: `authInfo` given to the faces is + * passed through to handlers and the factory as-is and is never derived from + * request headers. + */ +export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHandlerOptions = {}): McpHttpHandler { + const { legacy, onerror, responseMode } = options; + + /** Modern per-request instances with an exchange still in flight (close() tears these down). */ + const inflight = new Set(); + let closed = false; + let warnedJsonModeSubscriptions = false; + + const reportError = (error: Error) => { + try { + onerror?.(error); + } catch { + // Reporting must never alter the response. + } + }; + + const legacyHandler: LegacyHttpHandler | undefined = legacy === 'stateless' ? legacyStatelessFallback(factory, reportError) : legacy; + + async function serveModern( + route: InboundModernRoute, + message: JSONRPCRequest | JSONRPCNotification, + request: Request, + authInfo: AuthInfo | undefined + ): Promise { + const claimedRevision = route.classification.revision; + if (claimedRevision === undefined || !SUPPORTED_MODERN_PROTOCOL_VERSIONS.includes(claimedRevision)) { + // The claim names a revision this endpoint does not serve (an + // unknown future revision, or a 2025-era revision delivered via the + // envelope mechanism). + const error = new UnsupportedProtocolVersionError({ + supported: [...SUPPORTED_MODERN_PROTOCOL_VERSIONS], + requested: claimedRevision ?? 'unknown' + }); + reportError(error); + 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 }), + requestInfo: request + }); + const server = product instanceof McpServer ? product.server : product; + + // Era-write at instance binding, then modern-only handler installation — + // both before the instance is connected to the per-request transport. + setNegotiatedProtocolVersion(server, claimedRevision); + installModernOnlyHandlers(server, SUPPORTED_MODERN_PROTOCOL_VERSIONS); + + if (meta !== undefined) { + seedClientIdentityFromEnvelope(server, { + clientInfo: meta[CLIENT_INFO_META_KEY] as Implementation | undefined, + clientCapabilities: declaredClientCapabilities + }); + } + + if (responseMode === 'json' && !warnedJsonModeSubscriptions && hasConfiguredSubscriptions(product)) { + warnedJsonModeSubscriptions = true; + // eslint-disable-next-line no-console + console.warn( + "Warning: responseMode: 'json' drops mid-call notifications, but this server configures subscriptions. " + + 'Subscription (listen) streams are always served over SSE; other notifications emitted before a result will be dropped.' + ); + } + + // Track the instance until its exchange tears down so close() can abort it. + const previousOnClose = server.onclose; + inflight.add(server); + server.onclose = () => { + inflight.delete(server); + previousOnClose?.(); + }; + + // Listen-class streams are always SSE: even under 'json', a listen + // request's per-request transport keeps the lazy upgrade available. + const effectiveResponseMode: PerRequestResponseMode | undefined = + responseMode === 'json' && route.messageKind === 'request' && (message as JSONRPCRequest).method === 'subscriptions/listen' + ? 'auto' + : responseMode; + + try { + const response = await invoke(product, message, { + classification: route.classification, + request, + ...(authInfo !== undefined && { authInfo }), + ...(effectiveResponseMode !== undefined && { responseMode: effectiveResponseMode }) + }); + if (route.messageKind === 'notification') { + // Notification exchanges have no terminal response to ride the + // transport's auto-close, so release the per-request instance here. + queueMicrotask(() => void server.close().catch(() => {})); + } + return response; + } catch (error) { + if (error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed) { + // The client went away before a response existed; there is + // nobody left to answer. + return new Response(null, { status: 499 }); + } + // No terminal response will ride the transport's close chain after a + // failure here: close the per-request instance explicitly and drop it + // from the in-flight set so repeated failures cannot accumulate + // connected instances until handler.close(). + await server.close().catch(() => {}); + inflight.delete(server); + reportError(toError(error)); + return internalServerErrorResponse(echoableRequestId(message)); + } + } + + async function serveLegacyRoute( + route: InboundLegacyRoute, + forwardRequest: Request, + authInfo: AuthInfo | undefined, + parsedBody: unknown + ): Promise { + if (legacyHandler !== undefined) { + return legacyHandler(forwardRequest, { + ...(authInfo !== undefined && { authInfo }), + ...(parsedBody !== undefined && { parsedBody }) + }); + } + const strict = modernOnlyStrictRejection(route, SUPPORTED_MODERN_PROTOCOL_VERSIONS); + if (strict === undefined) { + // Legacy-classified notification on a modern-only endpoint: + // acknowledged and dropped, never dispatched. + return new Response(null, { status: 202 }); + } + reportError(new Error(`Rejected 2025-era request on a modern-only endpoint (${strict.cell}): ${strict.message}`)); + return rejectionResponse(strict, echoableRequestId(parsedBody)); + } + + async function handle(request: Request, requestOptions?: McpHandlerRequestOptions): Promise { + const httpMethod = request.method.toUpperCase(); + const authInfo = requestOptions?.authInfo; + + let body: unknown; + let parsedBody = requestOptions?.parsedBody; + let forwardRequest = request; + let unparseable = false; + + if (httpMethod === 'POST') { + if (parsedBody === undefined) { + // Read the body exactly once for classification, keeping an + // unread copy of the original bytes for the legacy slot + // (web-standard request bodies are single-use). + forwardRequest = request.clone(); + let bodyText: string; + try { + bodyText = await request.text(); + } catch { + return jsonRpcErrorResponse(400, -32_700, 'Parse error: the request body could not be read'); + } + try { + body = bodyText.length === 0 ? undefined : JSON.parse(bodyText); + } catch { + unparseable = true; + } + if (!unparseable && body !== undefined) { + parsedBody = body; + } + } else { + body = parsedBody; + } + + if (unparseable || body === undefined) { + // No JSON body to classify: there is no envelope claim, so this + // is legacy traffic when a slot is configured (the legacy leg + // answers its own parse error, unchanged), and a parse error + // otherwise. + if (legacyHandler !== undefined) { + return legacyHandler(forwardRequest, { ...(authInfo !== undefined && { authInfo }) }); + } + return jsonRpcErrorResponse(400, -32_700, 'Parse error: the request body is not valid JSON'); + } + } + + const outcome = classifyInboundRequest({ + httpMethod, + protocolVersionHeader: request.headers.get('mcp-protocol-version') ?? undefined, + mcpMethodHeader: request.headers.get('mcp-method') ?? undefined, + ...(body !== undefined && { body }) + }); + + try { + switch (outcome.kind) { + case 'reject': { + reportError(new Error(`Rejected inbound request (${outcome.cell}): ${outcome.message}`)); + return rejectionResponse(outcome, echoableRequestId(body)); + } + case 'modern': { + return await serveModern(outcome, body as JSONRPCRequest | JSONRPCNotification, request, authInfo); + } + case 'legacy': { + return await serveLegacyRoute(outcome, forwardRequest, authInfo, parsedBody); + } + } + } catch (error) { + // Entry-internal failure while serving a classified request (a + // throwing factory, a failed connect, a throwing bring-your-own + // legacy handler): the parsed body is in scope here, so the 500 + // body echoes the request id when it could be read. + reportError(toError(error)); + return internalServerErrorResponse(echoableRequestId(body)); + } + } + + const fetchFace = async (request: Request, requestOptions?: McpHandlerRequestOptions): Promise => { + if (closed) { + throw new Error('This MCP handler has been closed'); + } + try { + return await handle(request, requestOptions); + } catch (error) { + reportError(toError(error)); + return internalServerErrorResponse(echoableRequestId(requestOptions?.parsedBody)); + } + }; + + const nodeFace = async (req: NodeIncomingMessageLike, res: NodeServerResponseLike, parsedBody?: unknown): Promise => { + // Express passes (req, res, next) when the handler is mounted as a + // middleware function; a function third argument is `next`, not a body. + if (typeof parsedBody === 'function') { + parsedBody = undefined; + } + + let finished = false; + const abort = new AbortController(); + res.on('close', () => { + if (!finished) { + abort.abort(); + } + }); + + let response: Response; + try { + const request = await nodeRequestToFetchRequest(req, parsedBody, abort.signal); + response = await fetchFace(request, { + ...(req.auth !== undefined && { authInfo: req.auth }), + ...(parsedBody !== undefined && { parsedBody }) + }); + } catch (error) { + reportError(toError(error)); + response = internalServerErrorResponse(echoableRequestId(parsedBody)); + } + + const headers: Record = {}; + for (const [name, value] of response.headers) { + headers[name] = value; + } + res.writeHead(response.status, headers); + if (response.body === null) { + finished = true; + res.end(); + return; + } + const reader = response.body.getReader(); + // Honor write backpressure: when write() reports a full buffer (Node's + // `false` return), wait for the response to drain before pulling the + // next chunk. A single listener resolves whichever wait is pending; a + // closed response also releases the wait so a vanished client cannot + // park the loop forever. + let drainResolve: (() => void) | undefined; + const releaseDrainWait = () => { + drainResolve?.(); + drainResolve = undefined; + }; + res.on('drain', releaseDrainWait); + res.on('close', releaseDrainWait); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (value !== undefined && res.write(value) === false) { + await new Promise(resolve => { + drainResolve = resolve; + }); + } + } + } catch { + // The client went away while streaming; the abort signal already + // cancelled the exchange. + } + finished = true; + res.end(); + }; + + return { + fetch: fetchFace, + node: nodeFace, + close: async () => { + closed = true; + const closing = [...inflight].map(server => server.close().catch(() => {})); + inflight.clear(); + await Promise.all(closing); + } + }; +} + +/* ------------------------------------------------------------------------ * + * Node request conversion (duck-typed; no node: imports) + * ------------------------------------------------------------------------ */ + +function singleHeaderValue(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + +async function nodeRequestToFetchRequest(req: NodeIncomingMessageLike, parsedBody: unknown, signal: AbortSignal): Promise { + const method = (req.method ?? 'GET').toUpperCase(); + const host = singleHeaderValue(req.headers['host']) ?? 'localhost'; + const url = `http://${host}${req.url ?? '/'}`; + + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers)) { + // HTTP/2 pseudo-headers (`:method`, `:path`, `:authority`, …) are + // connection metadata, not header fields — `Headers` rejects their + // names, so they are skipped rather than copied. + if (value === undefined || name.startsWith(':')) { + continue; + } + if (Array.isArray(value)) { + for (const item of value) { + headers.append(name, item); + } + } else { + headers.set(name, value); + } + } + + // The body is carried as text: MCP request bodies are JSON, and a string + // body keeps the constructed Request portable across runtime lib versions. + let body: string | undefined; + if (method !== 'GET' && method !== 'HEAD') { + if (parsedBody === undefined) { + const decoder = new TextDecoder(); + let collected = ''; + for await (const chunk of req) { + collected += typeof chunk === 'string' ? chunk : decoder.decode(chunk as Uint8Array, { stream: true }); + } + collected += decoder.decode(); + if (collected.length > 0) { + body = collected; + } + } else { + // The caller already consumed and parsed the Node stream (the + // documented `handler.node(req, res, req.body)` mounting behind + // `express.json()`), so the bytes cannot be re-read. Re-serialize + // the parsed value so consumers of the forwarded Request — a + // bring-your-own legacy handler reading `request.json()`/`text()` + // in particular — still receive the body, and replace the entity + // headers that described the original raw bytes. + const serialized: string | undefined = JSON.stringify(parsedBody); + headers.delete('content-encoding'); + headers.delete('transfer-encoding'); + if (serialized === undefined) { + headers.delete('content-length'); + } else { + body = serialized; + headers.set('content-length', String(new TextEncoder().encode(serialized).byteLength)); + } + } + } + + return new Request(url, { + method, + headers, + signal, + ...(body !== undefined && { body }) + }); +} diff --git a/packages/server/src/server/invoke.ts b/packages/server/src/server/invoke.ts new file mode 100644 index 0000000000..f6c9c11359 --- /dev/null +++ b/packages/server/src/server/invoke.ts @@ -0,0 +1,68 @@ +/** + * The internal per-request invoke seam for modern-era HTTP serving. + * + * One classified inbound message is served by composing existing pieces, with + * no changes to the protocol dispatch layer: + * + * server instance (from the consumer's factory) + * → `connect(per-request transport)` + * → inject the classified message through the transport's message callback + * → capture the value (a single JSON body or an SSE stream) via the + * transport's send path. + * + * The seam is value-returning and independently testable: it resolves with the + * HTTP `Response` for the exchange. Marking factory instances as modern-era + * (and installing modern-only handlers) is the calling entry's responsibility + * and happens before this seam runs; the seam itself never writes era state. + */ +import type { AuthInfo, JSONRPCNotification, JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core'; + +import type { McpServer } from './mcp.js'; +import type { PerRequestResponseMode } from './perRequestTransport.js'; +import { PerRequestHTTPServerTransport } from './perRequestTransport.js'; +import type { Server } from './server.js'; + +/** Per-exchange context for {@linkcode invoke}. */ +export interface InvokeContext { + /** The edge classification of the message (computed once, at the entry boundary). */ + classification: MessageClassification; + /** The original HTTP request, when serving HTTP traffic. */ + request?: globalThis.Request; + /** + * Validated authentication information supplied by the caller. Strictly + * pass-through — never derived from request headers by this seam. + */ + authInfo?: AuthInfo; + /** Response shaping for the exchange; defaults to `auto` (lazy SSE upgrade). */ + responseMode?: PerRequestResponseMode; +} + +/** + * Serves one classified inbound message on the given server instance and + * returns the HTTP response for the exchange. + * + * The instance is connected to a fresh single-exchange transport, the message + * is injected through the normal transport message path, and whatever the + * dispatch layer produces (the handler result, a protocol-level rejection, or + * streamed related messages followed by the result) is captured as the + * returned `Response`. For request exchanges, teardown rides the transport's + * close chain once the terminal response has been delivered; notification + * exchanges resolve with the 202 response immediately and do NOT run the + * close chain — the transport stays connected until the caller closes it or + * drops the per-request instance, which is the caller's choice either way. + */ +export async function invoke( + server: Server | McpServer, + message: JSONRPCRequest | JSONRPCNotification, + ctx: InvokeContext +): Promise { + const transport = new PerRequestHTTPServerTransport({ + classification: ctx.classification, + ...(ctx.responseMode !== undefined && { responseMode: ctx.responseMode }) + }); + await server.connect(transport); + return transport.handleMessage(message, { + ...(ctx.request !== undefined && { request: ctx.request }), + ...(ctx.authInfo !== undefined && { authInfo: ctx.authInfo }) + }); +} diff --git a/packages/server/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/middleware/originValidation.ts b/packages/server/src/server/middleware/originValidation.ts new file mode 100644 index 0000000000..9b8b68c11e --- /dev/null +++ b/packages/server/src/server/middleware/originValidation.ts @@ -0,0 +1,98 @@ +/** + * Framework-agnostic Origin header validation helpers. + * + * Browsers attach an `Origin` header to cross-origin requests; validating it + * against an allowlist (alongside Host header validation) protects local and + * development MCP servers against DNS rebinding and cross-site request + * forgery. The framework middleware packages (`@modelcontextprotocol/express`, + * `@modelcontextprotocol/hono`, `@modelcontextprotocol/fastify`, + * `@modelcontextprotocol/node`) wrap these helpers; use them directly when + * mounting a handler bare on a fetch-native runtime. + * + * Validation is deny-on-failure: a present `Origin` value that cannot be + * parsed (including the opaque `null` origin) is rejected, never passed + * through. Requests without an `Origin` header pass — non-browser MCP clients + * do not send one. + */ + +export type OriginValidationResult = + | { ok: true; origin?: string; hostname?: string } + | { + ok: false; + errorCode: 'invalid_origin_header' | 'invalid_origin'; + message: string; + originHeader?: string; + hostname?: string; + }; + +/** + * Validate an `Origin` header against an allowlist of hostnames (port-agnostic). + * + * - A missing/empty `Origin` header passes: non-browser clients do not send one, + * and only browser-originated requests carry the header this check defends against. + * - Allowlist items are hostnames only (no scheme, no port), the same convention as + * `validateHostHeader`. For IPv6, include brackets (e.g. `[::1]`). + * - Any present value that cannot be parsed as an origin URL — including the literal + * `null` origin browsers send for opaque contexts — is rejected (deny on failure). + */ +export function validateOriginHeader(originHeader: string | null | undefined, allowedOriginHostnames: string[]): OriginValidationResult { + if (originHeader === null || originHeader === undefined || originHeader === '') { + return { ok: true }; + } + + let hostname: string; + try { + hostname = new URL(originHeader).hostname; + } catch { + return { ok: false, errorCode: 'invalid_origin_header', message: `Invalid Origin header: ${originHeader}`, originHeader }; + } + if (hostname === '') { + // Opaque origins ("null") and other non-hierarchical values parse without a + // hostname; they can never be allowlisted. + return { ok: false, errorCode: 'invalid_origin_header', message: `Invalid Origin header: ${originHeader}`, originHeader }; + } + + if (!allowedOriginHostnames.includes(hostname)) { + return { ok: false, errorCode: 'invalid_origin', message: `Invalid Origin: ${hostname}`, originHeader, hostname }; + } + + return { ok: true, origin: originHeader, hostname }; +} + +/** + * Convenience allowlist of localhost-class origin hostnames, mirroring + * `localhostAllowedHostnames`. + */ +export function localhostAllowedOrigins(): string[] { + return ['localhost', '127.0.0.1', '[::1]']; +} + +/** + * Web-standard `Request` helper for Origin validation: returns a `403` JSON-RPC + * error response when the request's `Origin` header is not allowed, and + * `undefined` when the request may proceed. + * + * ```ts + * const rejected = originValidationResponse(request, localhostAllowedOrigins()); + * if (rejected) return rejected; + * ``` + */ +export function originValidationResponse(req: Request, allowedOriginHostnames: string[]): Response | undefined { + const result = validateOriginHeader(req.headers.get('origin'), allowedOriginHostnames); + if (result.ok) return undefined; + + return Response.json( + { + jsonrpc: '2.0', + error: { + code: -32_000, + message: result.message + }, + id: null + }, + { + status: 403, + headers: { 'Content-Type': 'application/json' } + } + ); +} diff --git a/packages/server/src/server/perRequestTransport.ts b/packages/server/src/server/perRequestTransport.ts new file mode 100644 index 0000000000..74eaaca51a --- /dev/null +++ b/packages/server/src/server/perRequestTransport.ts @@ -0,0 +1,404 @@ +/** + * A single-exchange, per-request HTTP server transport for modern-era + * (protocol revision 2026-07-28) serving. + * + * One transport instance serves exactly one already-classified inbound + * JSON-RPC message and produces exactly one HTTP `Response`: + * + * - a `202` with no body for notifications, + * - a single JSON body for requests whose handler produces no streamed + * output, or + * - a lazily-opened SSE stream when the handler emits related messages + * (notifications or server-to-client requests) before its result — the + * stream carries those messages and finally the terminal result, then + * closes. + * + * The transport is constructed already-classified: the entry parses and + * classifies the request body exactly once and hands the classification in via + * the constructor; the transport attaches it (together with the original + * request and any caller-provided auth info) to every message it delivers, and + * the protocol layer validates it against the serving instance's negotiated + * era. `authInfo` is strictly pass-through — it is never derived from the + * inbound request's headers here. + * + * Deliberately NOT carried over from the session-oriented streamable HTTP + * transport: session ids and session headers, resumability (event ids, + * priming events, `Last-Event-ID` replay, retry hints), the standalone GET + * stream, and request-header validation (which belongs to middleware). The + * exchange is single-use; serving another request requires a new transport + * (and, in the per-request serving model, a fresh server instance). + */ +import type { + AuthInfo, + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + MessageClassification, + MessageExtraInfo, + RequestId, + Transport, + TransportSendOptions +} from '@modelcontextprotocol/core'; +import { + isJSONRPCErrorResponse, + isJSONRPCRequest, + isJSONRPCResultResponse, + LADDER_ERROR_HTTP_STATUS, + SdkError, + SdkErrorCode +} from '@modelcontextprotocol/core'; + +/** + * How the transport shapes its HTTP response for a request: + * + * - `auto` (default): answer with a single JSON body unless the handler emits + * a related message before its result, in which case the response upgrades + * to an SSE stream. + * - `sse`: always answer handler output over an SSE stream. The stream opens + * once the request has passed the pre-dispatch validation gates, so ladder + * rejections keep their mapped HTTP status instead of being framed onto a + * 200 stream. + * - `json`: never stream; related messages other than the terminal response + * are dropped. + */ +export type PerRequestResponseMode = 'auto' | 'sse' | 'json'; + +/** Constructor options for {@linkcode PerRequestHTTPServerTransport}. */ +export interface PerRequestHTTPServerTransportOptions { + /** The edge classification of the message this transport will serve. */ + classification: MessageClassification; + /** Response shaping for the exchange; defaults to `auto`. */ + responseMode?: PerRequestResponseMode; +} + +/** Per-exchange context handed to {@linkcode PerRequestHTTPServerTransport.handleMessage}. */ +export interface PerRequestMessageExtra { + /** + * The original HTTP request. Used for handler context and, when the + * runtime provides an abort signal on it, to cancel the exchange when the + * client disconnects. + */ + request?: globalThis.Request; + /** + * Validated authentication information supplied by the caller. Strictly + * pass-through: the transport never populates this from request headers. + */ + authInfo?: AuthInfo; +} + +interface DeferredResponse { + promise: Promise; + resolve: (response: Response) => void; + reject: (error: Error) => void; + settled: boolean; +} + +interface SseSink { + controller: ReadableStreamDefaultController; + encoder: InstanceType; + closed: boolean; +} + +/** + * The per-request micro-transport: a real, connected `Transport` whose whole + * lifetime is one HTTP exchange. See the module documentation for the + * response shapes it produces. + */ +export class PerRequestHTTPServerTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: T, extra?: MessageExtraInfo) => void; + + private readonly _classification: MessageClassification; + private readonly _responseMode: PerRequestResponseMode; + + private _started = false; + private _used = false; + private _closed = false; + private _terminalDelivered = false; + /** + * `true` only while the inbound message is being delivered synchronously + * to the connected protocol layer. The pre-handler gates (the era + * registry gate, the edge→instance handoff check, the missing-handler + * rejection) answer inside this window; request handlers always run + * after it (the protocol layer defers them to a microtask). An error + * sent inside the window is therefore ladder-originated, and an error + * sent after it is handler-produced. + */ + private _dispatchWindowOpen = false; + private _requestId?: RequestId; + private _deferredResponse?: DeferredResponse; + private _sse?: SseSink; + private _abortCleanup?: () => void; + + constructor(options: PerRequestHTTPServerTransportOptions) { + this._classification = options.classification; + this._responseMode = options.responseMode ?? 'auto'; + } + + async start(): Promise { + if (this._started) { + throw new Error('PerRequestHTTPServerTransport is already started'); + } + this._started = true; + } + + /** + * Serves the single exchange: delivers the classified message to the + * connected server instance and resolves with the HTTP response. + * + * Throws when called a second time (the transport is strictly + * single-use), or before a server has been connected to the transport. + * The returned promise rejects with a connection-closed error when the + * transport is closed before a response was produced (for example because + * the client disconnected). + */ + async handleMessage(message: JSONRPCRequest | JSONRPCNotification, extra?: PerRequestMessageExtra): Promise { + if (this._used) { + throw new Error('PerRequestHTTPServerTransport serves exactly one exchange; construct a new transport per request'); + } + if (!this._started || this.onmessage === undefined) { + throw new Error('PerRequestHTTPServerTransport is not connected: connect a server to this transport before handling a message'); + } + if (this._closed) { + throw new Error('PerRequestHTTPServerTransport is closed'); + } + this._used = true; + + const signal = extra?.request?.signal; + if (signal?.aborted) { + await this.close(); + throw new SdkError(SdkErrorCode.ConnectionClosed, 'The request was aborted before it could be handled'); + } + + // authInfo is strictly pass-through from the caller; it is never + // derived from the inbound request's headers. + const messageExtra: MessageExtraInfo = { + classification: this._classification, + ...(extra?.request !== undefined && { request: extra.request }), + ...(extra?.authInfo !== undefined && { authInfo: extra.authInfo }) + }; + + if (isJSONRPCRequest(message)) { + this._requestId = message.id; + + let resolve!: (response: Response) => void; + let reject!: (error: Error) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + this._deferredResponse = { promise, resolve, reject, settled: false }; + + if (signal !== undefined) { + const onAbort = () => void this.close(); + signal.addEventListener('abort', onAbort, { once: true }); + this._abortCleanup = () => signal.removeEventListener('abort', onAbort); + } + + this._dispatchWindowOpen = true; + try { + this.onmessage(message, messageExtra); + } finally { + this._dispatchWindowOpen = false; + } + + if (this._responseMode === 'sse' && !this._closed && !this._deferredResponse.settled) { + // Forced-SSE exchanges open their stream as soon as the + // request has passed the pre-dispatch gates: a ladder + // rejection settles inside the dispatch window with its + // mapped HTTP status, while handler output — including + // comment frames written before the first message — streams + // as before. + this.upgradeToSse(); + } + return promise; + } + + // Notifications never get a JSON-RPC response: deliver the message and + // acknowledge the POST with 202 and no body. + this.onmessage(message, messageExtra); + return new Response(null, { status: 202 }); + } + + async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { + if (this._closed) { + // The exchange is over; late writes are dropped. + return; + } + + const isResponse = isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message); + const relatedId = isResponse ? (message as { id: RequestId }).id : options?.relatedRequestId; + + if (this._requestId === undefined || relatedId === undefined || relatedId !== this._requestId) { + if (isResponse) { + this.onerror?.(new Error(`Received a response for an unknown request id: ${String((message as { id?: unknown }).id)}`)); + } + // Messages unrelated to the single in-flight request have nowhere + // to go on a per-request exchange (there is no session-wide + // stream); they are dropped. + return; + } + + if (isResponse) { + if (this._terminalDelivered) { + return; + } + this._terminalDelivered = true; + + // The HTTP status is keyed on the error's origin, not on its bare + // code: only errors produced inside the dispatch window — the + // validation ladder, the era registry gate and handoff check, a + // missing handler — are answered with the mapped HTTP status from + // the ladder table. Handler-produced errors, whatever their code, + // stay in-band on HTTP 200. Ladder rejections keep that mapped + // status in every response mode (the SSE upgrade is deferred to + // the first actual send), so a forced-`sse` exchange still + // answers pre-dispatch rejections as plain HTTP errors. + const ladderStatus = + this._dispatchWindowOpen && isJSONRPCErrorResponse(message) + ? LADDER_ERROR_HTTP_STATUS[(message as JSONRPCErrorResponse).error.code] + : undefined; + if (ladderStatus !== undefined && this._sse === undefined) { + this.settleResponse(Response.json(message, { status: ladderStatus, headers: { 'Content-Type': 'application/json' } })); + queueMicrotask(() => void this.close()); + return; + } + + if (this._sse !== undefined || this._responseMode === 'sse') { + // Finalize the stream: serialize the terminal result onto it + // after everything already enqueued, then close. + if (this._sse === undefined) { + this.upgradeToSse(); + } + this.writeMessageFrame(message); + this.finalizeStream(); + return; + } + + // Single JSON body. + this.settleResponse(Response.json(message, { status: 200, headers: { 'Content-Type': 'application/json' } })); + queueMicrotask(() => void this.close()); + return; + } + + // A message related to the in-flight request that is not its terminal + // response: a mid-call notification or a server-to-client request + // emitted by the handler. + if (this._responseMode === 'json') { + // JSON responses cannot carry mid-call messages; they are dropped. + return; + } + if (this._sse === undefined) { + this.upgradeToSse(); + } + this.writeMessageFrame(message); + } + + /** + * Writes an SSE comment frame (a keep-alive heartbeat). Dropped when the + * exchange is not currently streaming. + */ + writeCommentFrame(comment: string): void { + if (this._closed || this._sse === undefined || this._sse.closed) { + return; + } + const frame = comment + .split('\n') + .map(line => `: ${line}`) + .join('\n'); + this.writeFrame(`${frame}\n\n`); + } + + async close(): Promise { + if (this._closed) { + return; + } + this._closed = true; + + this._abortCleanup?.(); + this._abortCleanup = undefined; + + if (this._sse !== undefined && !this._sse.closed) { + this._sse.closed = true; + try { + this._sse.controller.close(); + } catch { + // The stream was already closed or cancelled by the consumer. + } + } + + if (this._deferredResponse !== undefined && !this._deferredResponse.settled) { + this._deferredResponse.settled = true; + this._deferredResponse.reject(new SdkError(SdkErrorCode.ConnectionClosed, 'Connection closed before a response was produced')); + } + + this.onclose?.(); + } + + private settleResponse(response: Response): void { + if (this._deferredResponse === undefined || this._deferredResponse.settled) { + return; + } + this._deferredResponse.settled = true; + this._deferredResponse.resolve(response); + } + + private upgradeToSse(): void { + let controller!: ReadableStreamDefaultController; + const readable = new ReadableStream({ + start: streamController => { + controller = streamController; + }, + cancel: () => { + // The client went away mid-stream: tear the exchange down, + // which aborts the in-flight handler through the connected + // server's close chain. + void this.close(); + } + }); + this._sse = { controller, encoder: new TextEncoder(), closed: false }; + + this.settleResponse( + new Response(readable, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + // Disable proxy buffering so streamed messages are + // delivered as they are written. + 'X-Accel-Buffering': 'no' + } + }) + ); + } + + private finalizeStream(): void { + if (this._sse !== undefined && !this._sse.closed) { + this._sse.closed = true; + try { + this._sse.controller.close(); + } catch { + // The stream was already cancelled by the consumer. + } + } + queueMicrotask(() => void this.close()); + } + + private writeMessageFrame(message: JSONRPCMessage): void { + this.writeFrame(`event: message\ndata: ${JSON.stringify(message)}\n\n`); + } + + private writeFrame(frame: string): void { + if (this._sse === undefined || this._sse.closed) { + return; + } + try { + this._sse.controller.enqueue(this._sse.encoder.encode(frame)); + } catch (error) { + this.onerror?.(new Error(`Failed to write to the response stream: ${error}`)); + } + } +} diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index f96d8ec1bc..20e2995923 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -1,23 +1,30 @@ import type { BaseContext, + CacheableResultMethod, + CacheHint, ClientCapabilities, CreateMessageRequest, CreateMessageRequestParamsBase, CreateMessageRequestParamsWithTools, CreateMessageResult, CreateMessageResultWithTools, + DiscoverResult, ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, + EmptyResult, Implementation, InitializeRequest, InitializeResult, + JSONRPCNotification, JSONRPCRequest, JsonSchemaType, jsonSchemaValidator, ListRootsRequest, + ListRootsResult, LoggingLevel, LoggingMessageNotification, + MessageClassification, MessageExtraInfo, NotificationMethod, NotificationOptions, @@ -32,24 +39,33 @@ import type { ToolUseContent } from '@modelcontextprotocol/core'; import { - CallToolRequestSchema, - CallToolResultSchema, + assertValidCacheHint, + attachCacheHintFallback, + classifyInboundMessage, + codecForVersion, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - ElicitResultSchema, - EmptyResultSchema, + envelopeClaimVersion, + FIRST_MODERN_PROTOCOL_VERSION, + hasEnvelopeClaim, + isModernProtocolVersion, LATEST_PROTOCOL_VERSION, - ListRootsResultSchema, + legacyProtocolVersions, LoggingLevelSchema, mergeCapabilities, + modernProtocolVersions, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, + requestMetaOf, SdkError, - SdkErrorCode + SdkErrorCode, + SUPPORTED_MODERN_PROTOCOL_VERSIONS, + validateEnvelopeMeta } from '@modelcontextprotocol/core'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; +import * as z from 'zod/v4'; export type ServerOptions = ProtocolOptions & { /** @@ -78,8 +94,153 @@ export type ServerOptions = ProtocolOptions & { * @default Runtime-selected validator (AJV-backed on Node.js, `@cfworker/json-schema`-backed on browser/workerd runtimes) */ jsonSchemaValidator?: jsonSchemaValidator; + + /** + * Which protocol eras this server serves on its long-lived connection + * (e.g. stdio): the 2025-era `initialize` family, the 2026-07-28 + * per-request-envelope revision, or both. + * + * - `'legacy'` (the default) preserves exactly what existing code was + * written for: the server speaks the 2025-era protocol negotiated via + * `initialize`, never registers or advertises `server/discover`, and + * upgrading the SDK changes nothing about what the instance puts on the + * wire. + * - `'dual-era'` serves BOTH eras on the same connection, selecting the + * era per message: `initialize`-negotiated 2025 traffic is served as + * before, while messages carrying the 2026-07-28 per-request `_meta` + * envelope (including `server/discover`) are served on the modern era. + * Declaring dual-era support is an explicit act — the consumer asserts + * that the server is ready to serve modern-era requests. + * - `'modern'` is strict 2026-07-28-only: requests without the + * per-request envelope (including `initialize`) are answered with the + * unsupported-protocol-version error naming the supported revisions. + * + * Declaring `'dual-era'` or `'modern'` automatically adds the SDK's + * supported modern revisions to + * {@linkcode ProtocolOptions.supportedProtocolVersions}, and `'modern'` + * serves only those: a strict instance's supported-versions list (what + * `server/discover` advertises and version-mismatch errors name) is its + * modern subset. + * + * Opting in is one option away and the transport stays unchanged: + * + * ```ts + * const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { eraSupport: 'dual-era' }); + * await server.connect(new StdioServerTransport()); + * ``` + * + * A 2026-era revision in {@linkcode ProtocolOptions.supportedProtocolVersions} + * requires `'dual-era'` or `'modern'`; passing one on a (default) + * `'legacy'` instance throws a `TypeError` at construction. + * + * Per-request HTTP serving via `createMcpHandler` does not use this + * option: the entry classifies each request and binds the per-request + * instance itself. + * + * @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 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, 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 + * `cacheScope: 'private'`. Responses to 2025-era requests are never + * affected. Invalid values throw a `RangeError` at construction time. + */ + cacheHints?: Partial>; }; +/** + * Permissive params schema for the `server/discover` registration on servers + * that declared modern-era support. The discover request carries only the + * per-request `_meta` envelope, which the protocol layer lifts and validates + * before dispatch — and a long-lived dual-era instance is never bound to a + * single era, so the spec-method registration form (which resolves its + * dispatch schema from the instance era) cannot be used here. + */ +const DISCOVER_PARAMS_SCHEMA = z.looseObject({}); + +/** + * Whether a message's params carry a per-request envelope claim that is both + * well-formed and names a modern protocol revision. + * + * The per-message form of the inbound classifier's `initialize` precedence + * rule: only such a claim overrides the `initialize` ⇒ legacy-handshake + * classification — a message carrying a valid modern envelope is a modern + * request regardless of its method name, and the modern era then answers + * `initialize` exactly like any other method it does not define + * (method-not-found). A malformed claim, or one naming a pre-2026 revision, + * keeps the legacy-handshake routing unchanged. + */ +function carriesValidModernEnvelopeClaim(params: unknown): boolean { + if (!hasEnvelopeClaim(params)) { + return false; + } + const claimedVersion = envelopeClaimVersion(params); + if (claimedVersion === undefined || !isModernProtocolVersion(claimedVersion)) { + return false; + } + const meta = requestMetaOf(params); + return meta !== undefined && validateEnvelopeMeta(meta).length === 0; +} + +/* + * Package-internal hooks for the per-request (2026-07-28) HTTP serving entry. + * + * The connection-scoped client-identity fields and the modern-only handler set are + * private to `Server`; the per-request entry in this package needs to write/install + * them on the fresh instance it gets from a consumer factory. The static initializer + * below hands these module-scoped closures privileged access; the exported wrappers + * are imported by sibling modules in this package only and are deliberately NOT + * re-exported from the package index (they are not public API). + */ +let writeClientIdentity: (server: Server, identity: PerRequestClientIdentity) => void; +let installDiscoverHandler: (server: Server, servedModernVersions: readonly string[]) => void; + +/** Connection-scoped client-identity fields backfilled per request from a validated `_meta` envelope. */ +export interface PerRequestClientIdentity { + /** The client's name/version information, when the envelope carried it. */ + clientInfo?: Implementation; + /** The client's declared capabilities, when the envelope carried them. */ + clientCapabilities?: ClientCapabilities; +} + +/** + * Package-internal: backfills the connection-scoped client-identity fields of a + * per-request server instance from the request's validated `_meta` envelope, so the + * (deprecated) {@linkcode Server.getClientCapabilities} / {@linkcode Server.getClientVersion} + * accessors keep answering on instances that never see an `initialize` handshake. + * Not public API. + */ +export function seedClientIdentityFromEnvelope(server: Server, identity: PerRequestClientIdentity): void { + writeClientIdentity(server, identity); +} + +/** + * Package-internal: installs the modern-only `server/discover` handler on an instance + * the HTTP entry has marked as serving the 2026-07-28 era, and makes sure the modern + * revisions the entry serves appear in the instance's supported-versions list (so the + * discover advertisement and version-mismatch errors name them). Idempotent. + * Hand-constructed instances are unaffected: nothing else calls this, so they keep + * answering `-32601` unless their own supported-versions list opts into a modern + * revision. Not public API. + */ +export function installModernOnlyHandlers(server: Server, servedModernVersions: readonly string[]): void { + installDiscoverHandler(server, servedModernVersions); +} + /** * An MCP server on top of a pluggable transport. * @@ -90,10 +251,38 @@ export type ServerOptions = ProtocolOptions & { export class Server extends Protocol { private _clientCapabilities?: ClientCapabilities; private _clientVersion?: Implementation; - private _negotiatedProtocolVersion?: string; + + static { + writeClientIdentity = (server, identity) => { + if (identity.clientCapabilities !== undefined) { + server._clientCapabilities = identity.clientCapabilities; + } + if (identity.clientInfo !== undefined) { + server._clientVersion = identity.clientInfo; + } + }; + installDiscoverHandler = (server, servedModernVersions) => { + const missing = servedModernVersions.filter(version => !server._supportedProtocolVersions.includes(version)); + if (missing.length > 0) { + // Never mutate the existing array in place: the default supported-versions + // list is a shared module constant. + server._supportedProtocolVersions = [...server._supportedProtocolVersions, ...missing]; + } + server.setRequestHandler('server/discover', () => server._ondiscover()); + }; + } private _capabilities: ServerCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; + private _eraSupport: 'legacy' | 'dual-era' | 'modern'; + /** + * The protocol version a legacy `initialize` handshake negotiated on a + * dual-era instance. A dual-era instance is never bound to a single era + * (the era is selected per message), so the handshake result is recorded + * 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). @@ -111,15 +300,97 @@ export class Server extends Protocol { this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._instructions = options?.instructions; 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?.()); + if (this._eraSupport === 'legacy') { + // The default preserves exactly what the code was written for: + // 2025-era serving only, nothing 2026-era registered or + // advertised. Serving a 2026-era revision is a declared act — a + // modern revision in the supported list without that declaration + // is a configuration error, never a silent behavior change. + const modernVersions = modernProtocolVersions(this._supportedProtocolVersions); + if (modernVersions.length > 0) { + throw new TypeError( + `supportedProtocolVersions contains the protocol revision ${modernVersions[0]}, which this server does not serve ` + + `with the default eraSupport of 'legacy'. Declare { eraSupport: 'dual-era' } (serve both eras) or ` + + `{ eraSupport: 'modern' } (2026-era only) to serve it.` + ); + } + } else { + // server/discover is registered (and modern revisions advertised) + // only on servers that declared modern-era support; the served + // modern revisions are added to the supported list so the + // advertisement and version-mismatch errors name them (a new + // array — the shared default constant is never mutated). + const missing = SUPPORTED_MODERN_PROTOCOL_VERSIONS.filter(version => !this._supportedProtocolVersions.includes(version)); + if (missing.length > 0) { + this._supportedProtocolVersions = [...this._supportedProtocolVersions, ...missing]; + } + this.setRequestHandler('server/discover', { params: DISCOVER_PARAMS_SCHEMA }, () => this._ondiscover()); + if (this._eraSupport === 'modern') { + // A strict modern-only server serves only modern revisions, so + // the supported list is reduced to its modern subset — keeping + // the legacy entries would advertise revisions the instance + // never serves in the unsupported-protocol-version error's + // supported list, and `initialize` (the only other consumer of + // the legacy entries) is unreachable on a strict instance. + this._supportedProtocolVersions = modernProtocolVersions(this._supportedProtocolVersions); + // A strict modern-only server is bound to the modern era from + // construction: requests classified into the 2025 era are + // answered with the typed unsupported-protocol-version error + // naming the supported revisions, never served. + this._negotiatedProtocolVersion = this._supportedProtocolVersions[0]; + } + } + if (this._capabilities.logging) { this._registerLoggingHandler(); } } + /** + * Per-message era classification for long-lived dual-era channels (e.g. a + * stdio server that declared modern-era support). Active only when the + * consumer opted in: default (`'legacy'`) instances return `undefined`, + * which keeps their dispatch byte-identical to today's. Transport-edge + * classification (the per-request HTTP entry) always wins and never + * reaches this hook. + */ + protected override _classifyInbound(message: JSONRPCRequest | JSONRPCNotification): MessageClassification | 'drop' | undefined { + if (this._eraSupport === 'legacy') { + return undefined; + } + // `initialize` is the legacy handshake by definition — unless the + // message carries a valid envelope claim naming a modern revision, in + // which case the claim wins: the message is classified like any other + // enveloped message and served on the modern era, where the era + // registry answers `initialize` with the same plain method-not-found + // it answers every other method that era does not define. A malformed + // or absent claim, or a claim naming a pre-2026 revision, keeps the + // legacy-handshake classification from the per-message predicate. + if (message.method === 'initialize' && carriesValidModernEnvelopeClaim(message.params)) { + const claimedVersion = envelopeClaimVersion(message.params); + if (claimedVersion !== undefined) { + return { era: 'modern', revision: claimedVersion }; + } + } + return classifyInboundMessage(message); + } + /** * Registers the built-in `logging/setLevel` request handler. * @@ -140,19 +411,76 @@ export class Server extends Protocol { }); } + /** + * Era gate for context-related server→client requests, keyed off the era + * of the request currently being served (its classification). + * + * A long-lived dual-era instance is never bound to a single era, so the + * instance-level outbound era gate alone would let a handler that is + * serving a 2026-era request push a server→client wire request + * (sampling, elicitation, roots) onto the connection. The 2026-07-28 + * revision has no server→client JSON-RPC request channel, so the client + * drops the request and the call hangs until timeout. The request + * context therefore applies the same typed local error a strict + * `'modern'` instance raises, per request: spec methods absent from the + * served era's registry fail fast before anything reaches the transport. + * + * Scope: the context request path only (`ctx.mcpReq.send`, + * `ctx.mcpReq.elicitInput`, `ctx.mcpReq.requestSampling`). Related + * notifications, requests served on the legacy era, and instance-level + * senders used outside a request context are unaffected. + */ + private _assertContextRequestInServedEra(classification: MessageClassification | undefined, method: string): void { + if (classification === undefined) { + return; + } + const servedCodec = codecForVersion( + classification.revision ?? (classification.era === 'modern' ? FIRST_MODERN_PROTOCOL_VERSION : undefined) + ); + // Mirrors the outbound era gate: only spec methods missing from the + // served era are gated; methods the served era defines (and + // consumer-owned extension methods) resolve exactly as before. + if (servedCodec.hasRequestMethod(method) || !codecForVersion(undefined).hasRequestMethod(method)) { + return; + } + throw new SdkError( + SdkErrorCode.MethodNotSupportedByProtocolVersion, + `Server-to-client requests are not available on protocol revision ${servedCodec.era}: ` + + `'${method}' cannot be sent while serving a request on that revision. ` + + `Servers obtain client input through request results once multi-round-trip support is available.`, + { method, era: servedCodec.era } + ); + } + protected override buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ServerContext { // Only create http when there's actual HTTP transport info or auth info const hasHttpInfo = ctx.http || transportInfo?.request || transportInfo?.closeSSEStream || transportInfo?.closeStandaloneSSEStream; + const classification = transportInfo?.classification; + // Context-related server→client requests are gated by the era of the + // request being served (see _assertContextRequestInServedEra); + // related notifications (`notify`, `log`) are unaffected. + const baseSend = ctx.mcpReq.send as (request: { method: string }, ...rest: unknown[]) => Promise; + const send = ((request: { method: string }, ...rest: unknown[]) => { + this._assertContextRequestInServedEra(classification, request.method); + return baseSend(request, ...rest); + }) as BaseContext['mcpReq']['send']; return { ...ctx, mcpReq: { ...ctx.mcpReq, + send, // Deprecated as of protocol version 2026-07-28 (SEP-2577): `log` and // `requestSampling` remain functional during the deprecation window // (at least twelve months). See ServerContext for migration guidance. log: (level, data, logger) => this.sendLoggingMessage({ level, data, logger }), - elicitInput: (params, options) => this.elicitInput(params, options), - requestSampling: (params, options) => this.createMessage(params, options) + elicitInput: async (params, options) => { + this._assertContextRequestInServedEra(classification, 'elicitation/create'); + return this.elicitInput(params, options); + }, + requestSampling: async (params, options) => { + this._assertContextRequestInServedEra(classification, 'sampling/createMessage'); + return this.createMessage(params, options); + } }, http: hasHttpInfo ? { @@ -195,17 +523,36 @@ 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) => { - const validatedRequest = parseSchema(CallToolRequestSchema, request); + // Era-exact validation: the request and result schemas come from + // the instance era, resolved at dispatch time (the era gate + // guarantees tools/call exists on the serving era). + const codec = codecForVersion(this._negotiatedProtocolVersion); + const callToolRequestSchema = codec.requestSchema('tools/call'); + // The era registry entry IS the plain CallToolResult schema (the + // result map is aligned to the typed map — no widened unions), + // so no narrower surface is needed. + const callToolResultSchema = codec.resultSchema('tools/call'); + if (!callToolRequestSchema || !callToolResultSchema) { + throw new ProtocolError(ProtocolErrorCode.InternalError, 'No wire schema for tools/call in the resolved era'); + } + const validatedRequest = parseSchema(callToolRequestSchema, request); if (!validatedRequest.success) { const errorMessage = validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); @@ -214,7 +561,7 @@ export class Server extends Protocol { const result = await handler(request, ctx); - const validationResult = parseSchema(CallToolResultSchema, result); + const validationResult = parseSchema(callToolResultSchema, result); if (!validationResult.success) { const errorMessage = validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); @@ -375,11 +722,26 @@ export class Server extends Protocol { this._clientCapabilities = request.params.capabilities; this._clientVersion = request.params.clientInfo; - const protocolVersion = this._supportedProtocolVersions.includes(requestedVersion) + // A 2026-07-28-or-later revision is NEVER negotiated via the legacy + // `initialize` handshake — only ever selected through `server/discover` — + // so the accept check and counter-offer consult only the legacy subset. + const legacyVersions = legacyProtocolVersions(this._supportedProtocolVersions); + const protocolVersion = legacyVersions.includes(requestedVersion) ? requestedVersion - : (this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION); - - this._negotiatedProtocolVersion = protocolVersion; + : (legacyVersions[0] ?? LATEST_PROTOCOL_VERSION); + + // The negotiated version is the instance's connection state — it IS + // the wire-era selection for everything this instance sends and + // receives from here on (legacy handshake ⇒ a legacy-era version). + // The one exception is a dual-era instance: it serves both eras on + // the same long-lived connection, selecting the era per message, so + // the handshake never binds the instance — the result is recorded + // only for the initialize-scoped accessor. + if (this._eraSupport === 'dual-era') { + this._dualEraInitializeVersion = protocolVersion; + } else { + this._negotiatedProtocolVersion = protocolVersion; + } this.transport?.setProtocolVersion?.(protocolVersion); return { @@ -390,8 +752,29 @@ export class Server extends Protocol { }; } + /** + * Answers `server/discover` (protocol revision 2026-07-28). `supportedVersions` + * lists only modern revisions (2025-era versions are negotiated via `initialize`); + * the advertised capabilities exclude the listChanged/subscribe-class capabilities + * (see {@linkcode discoverAdvertisedCapabilities}). + */ + private _ondiscover(): DiscoverResult { + return { + supportedVersions: modernProtocolVersions(this._supportedProtocolVersions), + capabilities: discoverAdvertisedCapabilities(this.getCapabilities()), + serverInfo: this._serverInfo, + ...(this._instructions && { instructions: this._instructions }) + }; + } + /** * After initialization has completed, this will be populated with the client's reported capabilities. + * + * @deprecated Read client identity from the per-request handler context instead: on + * 2026-07-28 (per-request envelope) requests `ctx.mcpReq.envelope` carries the client's + * declared capabilities, while on 2025-era connections this accessor keeps returning the + * `initialize`-scoped value. The accessor remains functional — instances serving the + * 2026-07-28 era are backfilled per request from the validated envelope. */ getClientCapabilities(): ClientCapabilities | undefined { return this._clientCapabilities; @@ -399,6 +782,12 @@ export class Server extends Protocol { /** * After initialization has completed, this will be populated with information about the client's name and version. + * + * @deprecated Read client identity from the per-request handler context instead: on + * 2026-07-28 (per-request envelope) requests `ctx.mcpReq.envelope` carries the client's + * name and version, while on 2025-era connections this accessor keeps returning the + * `initialize`-scoped value. The accessor remains functional — instances serving the + * 2026-07-28 era are backfilled per request from the validated envelope. */ getClientVersion(): Implementation | undefined { return this._clientVersion; @@ -408,9 +797,18 @@ export class Server extends Protocol { * After initialization has completed, this will be populated with the protocol version negotiated * with the client (the version the server responded with during the initialize handshake), or * `undefined` before initialization. + * + * @deprecated Read the protocol revision from the per-request handler context instead: on + * 2026-07-28 (per-request envelope) requests `ctx.mcpReq.envelope` names the revision the + * request was sent for, while on 2025-era connections this accessor keeps returning the + * `initialize`-negotiated version. The accessor remains functional — instances serving the + * 2026-07-28 era report that revision. On a long-lived dual-era instance (`eraSupport: + * 'dual-era'`), where the era is selected per message, the accessor keeps its + * initialize-scoped semantics and reports what a legacy `initialize` handshake negotiated + * (or `undefined` when none ran). */ getNegotiatedProtocolVersion(): string | undefined { - return this._negotiatedProtocolVersion; + return this._negotiatedProtocolVersion ?? this._dualEraInitializeVersion; } /** @@ -420,8 +818,8 @@ export class Server extends Protocol { return this._capabilities; } - async ping() { - return this._requestWithSchema({ method: 'ping' }, EmptyResultSchema); + async ping(): Promise { + return this.request({ method: 'ping' }); } /** @@ -511,11 +909,16 @@ export class Server extends Protocol { } } - // Use different schemas based on whether tools are provided + // Use different schemas based on whether tools are provided. The + // result schema depends on the REQUEST params, which a method-keyed + // registry entry cannot express, so it goes through the explicit- + // schema path (still era-gated: sampling/createMessage is not a wire + // request on the 2026 era, so a modern-era instance fails with the + // typed era error before anything reaches the transport). if (params.tools) { - return this._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultWithToolsSchema, options); + return await this._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultWithToolsSchema, options); } - return this._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultSchema, options); + return await this._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultSchema, options); } /** @@ -535,7 +938,9 @@ export class Server extends Protocol { } const urlParams = params as ElicitRequestURLParams; - return this._requestWithSchema({ method: 'elicitation/create', params: urlParams }, ElicitResultSchema, options); + // Method-keyed request(): the era registry's plain + // ElicitResult schema is exactly the narrow surface. + return this.request({ method: 'elicitation/create', params: urlParams }, options); } case 'form': { if (!this._clientCapabilities?.elicitation?.form) { @@ -545,11 +950,7 @@ export class Server extends Protocol { const formParams: ElicitRequestFormParams = params.mode === 'form' ? (params as ElicitRequestFormParams) : { ...(params as ElicitRequestFormParams), mode: 'form' }; - const result = await this._requestWithSchema( - { method: 'elicitation/create', params: formParams }, - ElicitResultSchema, - options - ); + const result = await this.request({ method: 'elicitation/create', params: formParams }, options); if (result.action === 'accept' && result.content && formParams.requestedSchema) { try { @@ -612,8 +1013,8 @@ export class Server extends Protocol { * Remains functional during the deprecation window (at least twelve months). * Migrate to passing paths via tool parameters, resource URIs, or configuration. */ - async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions) { - return this._requestWithSchema({ method: 'roots/list', params }, ListRootsResultSchema, options); + async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions): Promise { + return this.request({ method: 'roots/list', params }, options); } /** @@ -654,3 +1055,28 @@ export class Server extends Protocol { return this.notification({ method: 'notifications/prompts/list_changed' }); } } + +/** + * The capability set a server advertises on `server/discover`: until the + * `subscriptions/listen` flow ships, the advertisement excludes the + * listChanged/subscribe-class capabilities, which a modern-era connection + * cannot be served yet. Pure — never mutates the input; the legacy + * `initialize` advertisement is untouched. + */ +export function discoverAdvertisedCapabilities(capabilities: ServerCapabilities): ServerCapabilities { + const advertised: ServerCapabilities = { ...capabilities }; + if (capabilities.tools) { + advertised.tools = { ...capabilities.tools }; + delete advertised.tools.listChanged; + } + if (capabilities.prompts) { + advertised.prompts = { ...capabilities.prompts }; + delete advertised.prompts.listChanged; + } + if (capabilities.resources) { + advertised.resources = { ...capabilities.resources }; + delete advertised.resources.listChanged; + delete advertised.resources.subscribe; + } + return advertised; +} diff --git a/packages/server/test/server/cacheHints.test.ts b/packages/server/test/server/cacheHints.test.ts new file mode 100644 index 0000000000..d865062bf6 --- /dev/null +++ b/packages/server/test/server/cacheHints.test.ts @@ -0,0 +1,272 @@ +/** + * 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, 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. + */ +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, ResourceTemplate } 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('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 => ({ + 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('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 => ({ + 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"'); + } + }); +}); diff --git a/packages/server/test/server/classificationCarrierPin.test.ts b/packages/server/test/server/classificationCarrierPin.test.ts new file mode 100644 index 0000000000..00e135dd0f --- /dev/null +++ b/packages/server/test/server/classificationCarrierPin.test.ts @@ -0,0 +1,131 @@ +/** + * B-2 rule pin: hand-wired legacy-transport traffic is NEVER + * Protocol-classified. + * + * Discriminator: messages delivered by the hand-wired streamable HTTP server + * transport carry `extra.request` (the HTTP side channel) but `extra.classification` + * stays UNSET — the carrier exists for edge classifiers (the 2026 entry), and + * the Protocol layer must not classify on their behalf. A modern-stamped body + * (full 2026 `_meta` envelope) pushed through a legacy transport gets today's + * exact legacy semantics, byte-identical to the same body without the envelope + * claim where the envelope does not participate (the reserved keys are lifted + * from `_meta`, exactly as for any legacy request carrying them). + */ +import type { MessageExtraInfo } from '@modelcontextprotocol/core'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { Server } from '../../src/server/server.js'; +import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp.js'; + +const MODERN = '2026-07-28'; + +async function setupHandWired() { + const server = new Server({ name: 'pin-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/call', async () => ({ content: [{ type: 'text', text: 'pinned' }] })); + server.setRequestHandler('tools/list', async () => ({ tools: [] })); + + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true + }); + await server.connect(transport); + + // Hand-wired observation point: chain onto the transport callback the same + // way a consumer wrapping the transport would (wrappable-after-connect). + const seen: Array<{ method?: string; extra?: MessageExtraInfo }> = []; + const previous = transport.onmessage; + transport.onmessage = (message, extra) => { + seen.push({ method: (message as { method?: string }).method, extra }); + previous?.(message, extra); + }; + + const post = async (body: unknown): Promise<{ status: number; text: string }> => { + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(body) + }) + ); + return { status: response.status, text: await response.text() }; + }; + + return { server, transport, seen, post }; +} + +const toolsCall = (meta?: Record) => ({ + jsonrpc: '2.0', + id: 7, + method: 'tools/call', + params: { + name: 'anything', + arguments: {}, + ...(meta !== undefined && { _meta: meta }) + } +}); + +const modernEnvelope = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'modern-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +describe('B-2: hand-wired legacy-transport traffic is never Protocol-classified', () => { + it('extra.request is set and extra.classification stays unset for every delivered message', async () => { + const { server, seen, post } = await setupHandWired(); + + await post(toolsCall()); + await post(toolsCall(modernEnvelope)); + + expect(seen.length).toBeGreaterThanOrEqual(2); + for (const { extra } of seen) { + expect(extra?.request).toBeInstanceOf(Request); + expect(extra?.classification).toBeUndefined(); + } + + await server.close(); + }); + + it('a modern-stamped body through the legacy transport gets today’s exact legacy semantics, byte-identical', async () => { + const plainSetup = await setupHandWired(); + const plainResponse = await plainSetup.post(toolsCall()); + await plainSetup.server.close(); + + const stampedSetup = await setupHandWired(); + const stampedResponse = await stampedSetup.post(toolsCall(modernEnvelope)); + await stampedSetup.server.close(); + + // Byte-identical response: the envelope claim does not flip an era, does + // not change the result shape, does not get echoed back. + expect(stampedResponse.status).toBe(plainResponse.status); + expect(stampedResponse.text).toBe(plainResponse.text); + expect(stampedResponse.text).toContain('pinned'); + expect(stampedResponse.text).not.toContain(MODERN); + }); + + it('a modern-stamped initialize through the legacy transport negotiates exactly like today (no modern era)', async () => { + const { server, post } = await setupHandWired(); + + const { status, text } = await post({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: MODERN, + capabilities: {}, + clientInfo: { name: 'modern-client', version: '1.0.0' }, + _meta: modernEnvelope + } + }); + + expect(status).toBe(200); + const parsed = JSON.parse(text) as { result: { protocolVersion: string } }; + // Today's exact legacy semantics: the unknown requested version is + // countered with the latest released version; the body stamp does not + // make the legacy transport modern. + expect(parsed.result.protocolVersion).toBe('2025-11-25'); + + await server.close(); + }); +}); diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts new file mode 100644 index 0000000000..a07df6f264 --- /dev/null +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -0,0 +1,870 @@ +/** + * createMcpHandler: the slot-model HTTP entry. + * + * Covers the three slot states (omitted → modern-only strict, 'stateless' → + * per-request legacy sugar, handler → bring-your-own), the handler faces, the + * per-request era write + client-identity backfill, notification routing, the + * response-mode knob, and close() teardown of the modern leg. + */ +import { Readable } from 'node:stream'; + +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; +import * as z from 'zod/v4'; + +import type { McpRequestContext, NodeServerResponseLike } from '../../src/server/createMcpHandler.js'; +import { createMcpHandler } from '../../src/server/createMcpHandler.js'; +import { McpServer } from '../../src/server/mcp.js'; +import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport.js'; + +const MODERN_REVISION = '2026-07-28'; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'entry-test-client', version: '3.2.1' }, + [CLIENT_CAPABILITIES_META_KEY]: { elicitation: { form: {} } } +}; + +interface JSONRPCErrorBody { + jsonrpc: string; + id: unknown; + error: { code: number; message: string; data?: Record }; +} + +function modernToolsCall(name: string, args: Record, envelope: Record = ENVELOPE): unknown { + return { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name, arguments: args, _meta: envelope } + }; +} + +function postRequest(body: unknown, headers: Record = {}): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + ...headers + }, + body: typeof body === 'string' ? body : JSON.stringify(body) + }); +} + +interface TestFactoryState { + contexts: McpRequestContext[]; + products: McpServer[]; + oninitializedCalls: number; +} + +function testFactory(): { factory: (ctx: McpRequestContext) => McpServer; state: TestFactoryState } { + const state: TestFactoryState = { contexts: [], products: [], oninitializedCalls: 0 }; + const factory = (ctx: McpRequestContext): McpServer => { + state.contexts.push(ctx); + const mcpServer = new McpServer({ name: 'entry-test-server', version: '1.0.0' }); + mcpServer.server.oninitialized = () => { + state.oninitializedCalls += 1; + }; + mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] + })); + mcpServer.registerTool('whoami', { inputSchema: z.object({}) }, async (_args, ctx2) => ({ + content: [{ type: 'text', text: ctx2.http?.authInfo?.clientId ?? 'anonymous' }] + })); + mcpServer.registerTool('progress-then-echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }, ctx2) => { + await ctx2.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'tok', progress: 1 } }); + return { content: [{ type: 'text', text }] }; + }); + mcpServer.registerTool('park', { inputSchema: z.object({}) }, async (_args, ctx2) => { + await new Promise(resolve => { + ctx2.mcpReq.signal.addEventListener('abort', () => resolve(), { once: true }); + }); + return { content: [{ type: 'text', text: 'aborted' }] }; + }); + state.products.push(mcpServer); + return mcpServer; + }; + return { factory, state }; +} + +describe('createMcpHandler — modern path', () => { + it('serves an envelope-carrying request on a fresh modern instance', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'hello' }))); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('hello'); + + expect(state.contexts).toHaveLength(1); + expect(state.contexts[0]?.era).toBe('modern'); + expect(state.contexts[0]?.requestInfo).toBeInstanceOf(Request); + }); + + it('serves server/discover on the modern path with the modern supported list', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const response = await handler.fetch( + postRequest({ jsonrpc: '2.0', id: 5, method: 'server/discover', params: { _meta: ENVELOPE } }) + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { supportedVersions: string[]; serverInfo: { name: string } } }; + expect(body.result.supportedVersions).toEqual([MODERN_REVISION]); + expect(body.result.serverInfo.name).toBe('entry-test-server'); + }); + + it('backfills the deprecated accessors and the negotiated revision from the validated envelope (per-request instance state)', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'x' }))); + expect(response.status).toBe(200); + + const server = state.products[0]!.server; + expect(server.getClientVersion()).toEqual({ name: 'entry-test-client', version: '3.2.1' }); + expect(server.getClientCapabilities()).toEqual({ elicitation: { form: {} } }); + expect(server.getNegotiatedProtocolVersion()).toBe(MODERN_REVISION); + }); + + it('never fires oninitialized on the modern path and never needs setProtocolVersion on the per-request transport', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + // A 2026-classified `notifications/initialized` (modern header, no body claim) + // is acknowledged but the era registry has no such notification, so the + // legacy lifecycle callback structurally cannot fire. + const response = await handler.fetch( + postRequest( + { jsonrpc: '2.0', method: 'notifications/initialized' }, + { 'mcp-protocol-version': MODERN_REVISION, 'mcp-method': 'notifications/initialized' } + ) + ); + expect(response.status).toBe(202); + expect(state.oninitializedCalls).toBe(0); + + // The legacy transport's setProtocolVersion side effect is moot by construction: + // the per-request transport does not implement the optional hook at all. + const transport = new PerRequestHTTPServerTransport({ classification: { era: 'modern', revision: MODERN_REVISION } }); + expect((transport as { setProtocolVersion?: unknown }).setProtocolVersion).toBeUndefined(); + }); + + it('passes caller-supplied authInfo through to handler context and never derives it from headers', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const withAuth = await handler.fetch(postRequest(modernToolsCall('whoami', {})), { + authInfo: { token: 'verified', clientId: 'client-7', scopes: [] } + }); + const withAuthBody = (await withAuth.json()) as { result: { content: Array<{ text: string }> } }; + expect(withAuthBody.result.content[0]?.text).toBe('client-7'); + + const withoutAuth = await handler.fetch(postRequest(modernToolsCall('whoami', {}), { authorization: 'Bearer raw-header-token' })); + const withoutAuthBody = (await withoutAuth.json()) as { result: { content: Array<{ text: string }> } }; + expect(withoutAuthBody.result.content[0]?.text).toBe('anonymous'); + }); + + it('answers era-removed and unknown methods with method-not-found over HTTP 404', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const eraRemoved = await handler.fetch( + postRequest({ jsonrpc: '2.0', id: 2, method: 'logging/setLevel', params: { level: 'info', _meta: ENVELOPE } }) + ); + expect(eraRemoved.status).toBe(404); + const eraRemovedBody = (await eraRemoved.json()) as JSONRPCErrorBody; + expect(eraRemovedBody.error.code).toBe(-32_601); + expect(eraRemovedBody.id).toBe(2); + + const unknown = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 3, method: 'no/such-method', params: { _meta: ENVELOPE } })); + expect(unknown.status).toBe(404); + const unknownBody = (await unknown.json()) as JSONRPCErrorBody; + expect(unknownBody.error.code).toBe(-32_601); + expect(unknownBody.id).toBe(3); + }); + + it('rejects an envelope claiming a revision the endpoint does not serve with the supported list', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + const response = await handler.fetch( + postRequest(modernToolsCall('echo', { text: 'x' }, { ...ENVELOPE, [PROTOCOL_VERSION_META_KEY]: '2030-01-01' })) + ); + expect(response.status).toBe(400); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_004); + expect(body.error.data?.['supported']).toEqual([MODERN_REVISION]); + expect(body.error.data?.['requested']).toBe('2030-01-01'); + expect(body.id).toBe(1); + expect(state.contexts).toHaveLength(0); + }); + + it('rejects a header/body protocol-version mismatch with -32001 (HeaderMismatch) over HTTP 400', async () => { + const { factory } = testFactory(); + const onerror = vi.fn(); + const handler = createMcpHandler(factory, { onerror }); + + const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'x' }), { 'mcp-protocol-version': '2025-11-25' })); + expect(response.status).toBe(400); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_001); + // The rejection echoes the request id. + expect(body.id).toBe(1); + expect(onerror).toHaveBeenCalled(); + }); + + it('rejects a modern-classified request without a _meta envelope with -32602 naming the missing key over HTTP 400', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + // The MCP-Protocol-Version header names the modern revision but the body + // carries no per-request envelope: invalid params naming what is missing, + // not a version error and not silent legacy serving. + const response = await handler.fetch( + postRequest( + { jsonrpc: '2.0', id: 11, method: 'tools/list', params: {} }, + { 'mcp-protocol-version': MODERN_REVISION, 'mcp-method': 'tools/list' } + ) + ); + expect(response.status).toBe(400); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_602); + expect(JSON.stringify(body.error.data)).toContain('_meta'); + expect(body.id).toBe(11); + expect(state.contexts).toHaveLength(0); + }); + + it('answers entry-internal failures with 500/-32603 and reports them through onerror', async () => { + const onerror = vi.fn(); + const handler = createMcpHandler( + () => { + throw new Error('factory exploded'); + }, + { onerror } + ); + + const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'x' }))); + expect(response.status).toBe(500); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_603); + expect(body.id).toBe(1); + expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: 'factory exploded' })); + }); + + it('closes and releases the per-request instance when a modern exchange fails internally', async () => { + const { factory, state } = testFactory(); + const onerror = vi.fn(); + let closeCalls = 0; + const failingFactory = (ctx: McpRequestContext): McpServer => { + const product = factory(ctx); + vi.spyOn(product.server, 'connect').mockRejectedValue(new Error('connect exploded')); + const realClose = product.server.close.bind(product.server); + product.server.close = async () => { + closeCalls += 1; + await realClose(); + }; + return product; + }; + const handler = createMcpHandler(failingFactory, { onerror }); + + const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'x' }))); + expect(response.status).toBe(500); + expect(((await response.json()) as JSONRPCErrorBody).error.code).toBe(-32_603); + expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: 'connect exploded' })); + expect(state.contexts).toHaveLength(1); + + // The failed exchange's instance was closed and released from the + // in-flight set: the handler's own close() finds nothing to tear down. + expect(closeCalls).toBe(1); + await handler.close(); + expect(closeCalls).toBe(1); + }); + + it('rejects a malformed envelope behind a present claim with invalid params naming the offending key', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + const response = await handler.fetch( + postRequest(modernToolsCall('echo', { text: 'x' }, { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION })) + ); + expect(response.status).toBe(400); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_602); + expect(JSON.stringify(body.error.data)).toContain('clientInfo'); + expect(body.id).toBe(1); + expect(state.contexts).toHaveLength(0); + }); +}); + +describe('createMcpHandler — modern-only strict (legacy slot omitted)', () => { + it('rejects envelope-less requests with the unsupported-protocol-version error and the supported list', async () => { + const { factory, state } = testFactory(); + const onerror = vi.fn(); + const handler = createMcpHandler(factory, { onerror }); + + const response = await handler.fetch( + postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'echo', arguments: { text: 'x' } } }) + ); + expect(response.status).toBe(400); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_004); + expect(body.error.data?.['supported']).toEqual([MODERN_REVISION]); + expect(body.id).toBe(1); + expect(state.contexts).toHaveLength(0); + expect(onerror).toHaveBeenCalled(); + }); + + it('rejects an envelope-less initialize naming the supported and requested versions', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const response = await handler.fetch( + postRequest({ + jsonrpc: '2.0', + id: 'init-1', + method: 'initialize', + params: { protocolVersion: '2025-11-25', clientInfo: { name: 'legacy', version: '1.0' }, capabilities: {} } + }) + ); + expect(response.status).toBe(400); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_004); + expect(body.error.data?.['supported']).toEqual([MODERN_REVISION]); + expect(body.error.data?.['requested']).toBe('2025-11-25'); + expect(body.id).toBe('init-1'); + }); + + it('answers GET and DELETE with 405 Method not allowed', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + for (const method of ['GET', 'DELETE']) { + const response = await handler.fetch(new Request('http://localhost/mcp', { method })); + expect(response.status).toBe(405); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_000); + expect(body.error.message).toBe('Method not allowed.'); + // Body-less methods carry no request id to echo. + expect(body.id).toBeNull(); + } + }); + + it('rejects batch and response-body POSTs as invalid requests', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const batch = await handler.fetch(postRequest([{ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }])); + expect(batch.status).toBe(400); + const batchBody = (await batch.json()) as JSONRPCErrorBody; + expect(batchBody.error.code).toBe(-32_600); + // A whole-array rejection corresponds to no single request: id stays null. + expect(batchBody.id).toBeNull(); + + const responseBody = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 9, result: { ok: true } })); + expect(responseBody.status).toBe(400); + const responseBodyJson = (await responseBody.json()) as JSONRPCErrorBody; + expect(responseBodyJson.error.code).toBe(-32_600); + // A posted response is not a request; there is no request id to echo. + expect(responseBodyJson.id).toBeNull(); + }); + + it('answers unparseable JSON with a parse error', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const response = await handler.fetch(postRequest('{not json')); + expect(response.status).toBe(400); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_700); + // The id could not be read from the malformed body, so it stays null. + expect(body.id).toBeNull(); + }); + + it('acknowledges and drops legacy-classified notifications (202, never dispatched)', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + const response = await handler.fetch( + postRequest({ jsonrpc: '2.0', method: 'notifications/initialized' }, { 'mcp-method': 'something/else' }) + ); + expect(response.status).toBe(202); + expect(await response.text()).toBe(''); + // Never dispatched: no instance was even constructed, and the Mcp-Method + // header is never enforced on legacy notifications. + expect(state.contexts).toHaveLength(0); + }); + + it('routes a notification POST by the modern header when the body carries no claim', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + const response = await handler.fetch( + postRequest( + { jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 1 } }, + { 'mcp-protocol-version': MODERN_REVISION } + ) + ); + expect(response.status).toBe(202); + expect(state.contexts).toHaveLength(1); + expect(state.contexts[0]?.era).toBe('modern'); + }); + + it('names the modern revisions in the strict rejection data so legacy clients can discover the endpoint era', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + const response = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} })); + const body = (await response.json()) as JSONRPCErrorBody; + // The strict rejection deliberately names the modern revisions so a legacy + // client can discover what the endpoint serves from the error alone. + expect(JSON.stringify(body.error.data)).toContain(MODERN_REVISION); + }); +}); + +describe('createMcpHandler — legacy: "stateless" sugar', () => { + it('serves a 2025-era client through the frozen stateless idiom with a fresh instance per request', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + const initialize = await handler.fetch( + postRequest({ + jsonrpc: '2.0', + id: 'init-1', + method: 'initialize', + params: { protocolVersion: '2025-11-25', clientInfo: { name: 'legacy-client', version: '1.0' }, capabilities: {} } + }) + ); + expect(initialize.status).toBe(200); + expect(await initialize.text()).toContain('"protocolVersion":"2025-11-25"'); + + const toolsCall = await handler.fetch( + postRequest({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'echo', arguments: { text: 'legacy hello' } } }) + ); + expect(toolsCall.status).toBe(200); + expect(await toolsCall.text()).toContain('legacy hello'); + + expect(state.contexts).toHaveLength(2); + expect(state.contexts.every(ctx => ctx.era === 'legacy')).toBe(true); + expect(state.products[0]).not.toBe(state.products[1]); + // Hand-shaped legacy serving never marks instances as modern. + expect(state.products[0]!.server.getNegotiatedProtocolVersion()).not.toBe(MODERN_REVISION); + }); + + it('answers GET and DELETE like the canonical stateless example (405, Method not allowed.)', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + for (const method of ['GET', 'DELETE']) { + const response = await handler.fetch(new Request('http://localhost/mcp', { method })); + expect(response.status).toBe(405); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_000); + expect(body.error.message).toBe('Method not allowed.'); + } + }); + + it('routes legacy notification POSTs to the legacy leg (202 acknowledged by the stateless transport)', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + const response = await handler.fetch(postRequest({ jsonrpc: '2.0', method: 'notifications/initialized' })); + expect(response.status).toBe(202); + expect(state.contexts).toHaveLength(1); + expect(state.contexts[0]?.era).toBe('legacy'); + }); + + it('routes all-legacy batch arrays to the legacy leg unchanged', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + const response = await handler.fetch( + postRequest([ + { jsonrpc: '2.0', method: 'notifications/initialized' }, + { jsonrpc: '2.0', method: 'notifications/roots/list_changed' } + ]) + ); + expect(response.status).toBe(202); + }); + + it('hands unparseable bodies to the legacy leg so the parse error stays the legacy transport answer', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + const response = await handler.fetch(postRequest('{not json')); + expect(response.status).toBe(400); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_700); + }); + + it('still serves the modern path on the same endpoint (one factory, both legs)', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + const modern = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'modern hello' }))); + expect(modern.status).toBe(200); + expect(await modern.text()).toContain('modern hello'); + expect(state.contexts[0]?.era).toBe('modern'); + }); + + it("reports legacy: 'stateless' leg failures through the entry's onerror instead of swallowing them", async () => { + const onerror = vi.fn(); + const handler = createMcpHandler( + ctx => { + if (ctx.era === 'legacy') { + throw new Error('legacy factory exploded'); + } + return new McpServer({ name: 'modern-only-product', version: '1.0.0' }); + }, + { legacy: 'stateless', onerror } + ); + + const response = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} })); + expect(response.status).toBe(500); + expect(((await response.json()) as JSONRPCErrorBody).error.code).toBe(-32_603); + expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: 'legacy factory exploded' })); + }); + + it('keeps classifier rejections authoritative on the dual arm (pins the current -32600 cells with a slot configured)', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + // Parsed-but-not-JSON-RPC single object: the entry's -32600, not the + // legacy transport's -32700. + const notJsonRpc = await handler.fetch(postRequest({ hello: 'world' })); + expect(notJsonRpc.status).toBe(400); + expect(((await notJsonRpc.json()) as JSONRPCErrorBody).error.code).toBe(-32_600); + + // Empty batch: the entry's -32600/400, not the legacy leg's 202 ack. + const emptyBatch = await handler.fetch(postRequest([])); + expect(emptyBatch.status).toBe(400); + expect(((await emptyBatch.json()) as JSONRPCErrorBody).error.code).toBe(-32_600); + + // A batch containing an invalid element is rejected on both arms (element-wise classification). + const mixedBatch = await handler.fetch(postRequest([{ jsonrpc: '2.0', method: 'notifications/initialized' }, { nope: true }])); + expect(mixedBatch.status).toBe(400); + expect(((await mixedBatch.json()) as JSONRPCErrorBody).error.code).toBe(-32_600); + + // The legacy leg is never consulted for these cells. + expect(state.contexts).toHaveLength(0); + }); + + it('answers a legacy-direction server/discover with a plain method-not-found and zero 2026 vocabulary', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + const response = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 4, method: 'server/discover', params: {} })); + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).toContain('-32601'); + expect(text).toContain('Method not found'); + expect(text).not.toContain('2026'); + }); +}); + +describe('createMcpHandler — legacy: bring-your-own handler', () => { + it('hands legacy-classified requests to the handler with the original bytes untouched', async () => { + const { factory, state } = testFactory(); + const original = { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }; + let receivedBody: string | undefined; + let receivedParsedBody: unknown; + const byo = vi.fn(async (request: Request, options?: { parsedBody?: unknown }) => { + receivedBody = await request.text(); + receivedParsedBody = options?.parsedBody; + return new Response('byo-served', { status: 299 }); + }); + const handler = createMcpHandler(factory, { legacy: byo }); + + const response = await handler.fetch(postRequest(original)); + expect(response.status).toBe(299); + expect(await response.text()).toBe('byo-served'); + expect(receivedBody).toBe(JSON.stringify(original)); + expect(receivedParsedBody).toEqual(original); + + // GET/DELETE are method-routed to the handler too (sessionful BYO wirings own them). + const get = await handler.fetch(new Request('http://localhost/mcp', { method: 'GET' })); + expect(get.status).toBe(299); + + // Modern envelope traffic never reaches the legacy slot. + const modern = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'hi' }))); + expect(modern.status).toBe(200); + expect(byo).toHaveBeenCalledTimes(2); + expect(state.contexts.filter(ctx => ctx.era === 'modern')).toHaveLength(1); + }); +}); + +describe('createMcpHandler — responseMode', () => { + it('defaults to the lazy upgrade: a handler emitting a related notification streams the exchange over SSE', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const response = await handler.fetch(postRequest(modernToolsCall('progress-then-echo', { text: 'streamed' }))); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/event-stream'); + const text = await response.text(); + expect(text).toContain('notifications/progress'); + expect(text).toContain('streamed'); + }); + + it("responseMode: 'json' never streams and drops mid-call notifications", async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory, { responseMode: 'json' }); + + const response = await handler.fetch(postRequest(modernToolsCall('progress-then-echo', { text: 'json only' }))); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + const text = await response.text(); + expect(text).not.toContain('notifications/progress'); + expect(text).toContain('json only'); + }); + + it("responseMode: 'sse' streams even when the handler emits nothing before its result", async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory, { responseMode: 'sse' }); + + const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'eager stream' }))); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/event-stream'); + expect(await response.text()).toContain('eager stream'); + }); +}); + +describe('createMcpHandler — handler faces', () => { + it('exposes a detach-safe fetch face', async () => { + const { factory } = testFactory(); + const { fetch: detachedFetch } = createMcpHandler(factory); + const response = await detachedFetch(postRequest(modernToolsCall('echo', { text: 'detached' }))); + expect(response.status).toBe(200); + expect(await response.text()).toContain('detached'); + }); + + it('serves through the duck-typed node face, reading the request stream when no parsed body is given', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const { req, res, body } = nodeRequestResponse(modernToolsCall('echo', { text: 'node face' })); + // Express mounts pass `next` as the third argument; a function is never a parsed body. + await handler.node(req, res, () => {}); + expect(res.statusCode).toBe(200); + expect(await body()).toContain('node face'); + }); + + it('prefers a pre-parsed body over the request stream on the node face', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const parsed = modernToolsCall('echo', { text: 'pre-parsed' }); + const { req, res, body } = nodeRequestResponse(undefined); + await handler.node(req, res, parsed); + expect(res.statusCode).toBe(200); + expect(await body()).toContain('pre-parsed'); + }); + + it('synthesizes the forwarded body from a pre-parsed body so node-face BYO legacy handlers can read it', async () => { + const { factory } = testFactory(); + const legacyMessage = { jsonrpc: '2.0', id: 7, method: 'tools/list', params: {} }; + let receivedText: string | undefined; + let receivedContentLength: string | null = null; + let receivedTransferEncoding: string | null = null; + const byo = async (request: Request) => { + receivedText = await request.text(); + receivedContentLength = request.headers.get('content-length'); + receivedTransferEncoding = request.headers.get('transfer-encoding'); + return new Response('byo-node-served', { status: 200 }); + }; + const handler = createMcpHandler(factory, { legacy: byo }); + + // The documented Express mounting: express.json() consumed the stream + // and hands the parsed object as the third argument; the raw headers + // still describe the original (already-consumed) bytes. + const { req, res, body } = nodeRequestResponse(undefined); + req.headers['content-length'] = '999'; + req.headers['transfer-encoding'] = 'chunked'; + await handler.node(req, res, legacyMessage); + + expect(res.statusCode).toBe(200); + expect(await body()).toBe('byo-node-served'); + expect(receivedText).toBe(JSON.stringify(legacyMessage)); + expect(receivedContentLength).toBe(String(JSON.stringify(legacyMessage).length)); + expect(receivedTransferEncoding).toBeNull(); + }); + + it('forwards req.auth from upstream middleware as pass-through authInfo on the node face', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const { req, res, body } = nodeRequestResponse(modernToolsCall('whoami', {})); + req.auth = { token: 'verified', clientId: 'node-client', scopes: [] }; + await handler.node(req, res); + expect(res.statusCode).toBe(200); + expect(await body()).toContain('node-client'); + }); + + it('skips HTTP/2 pseudo-headers when copying node request headers', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const { req, res, body } = nodeRequestResponse(modernToolsCall('echo', { text: 'http2 served' })); + Object.assign(req.headers, { + ':method': 'POST', + ':path': '/mcp', + ':scheme': 'http', + ':authority': 'localhost:3000' + }); + await handler.node(req, res); + + expect(res.statusCode).toBe(200); + expect(await body()).toContain('http2 served'); + }); + + it('waits for drain before writing the next chunk when res.write reports backpressure', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const writes: string[] = []; + const listeners = new Map void>>(); + const res: NodeServerResponseLike & { statusCode: number } = { + statusCode: 0, + writeHead(statusCode: number) { + this.statusCode = statusCode; + return this; + }, + write(chunk: string | Uint8Array) { + writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)); + // Always report a full buffer. + return false; + }, + end() { + return this; + }, + on(event: string, listener: (...args: unknown[]) => void) { + const existing = listeners.get(event) ?? []; + existing.push(listener); + listeners.set(event, existing); + return this; + } + }; + const emitDrain = () => { + for (const listener of listeners.get('drain') ?? []) { + listener(); + } + }; + + // The default (auto) response mode streams this exchange over SSE, so + // the loop sees at least two chunks (the progress frame and the result). + const { req } = nodeRequestResponse(modernToolsCall('progress-then-echo', { text: 'paced' })); + const served = handler.node(req, res); + + await vi.waitFor(() => expect(writes.length).toBe(1)); + // With the buffer reported full and no drain yet, no further chunk is written. + await new Promise(resolve => setTimeout(resolve, 25)); + expect(writes).toHaveLength(1); + + // Draining releases the loop chunk by chunk until the stream completes. + const pump = setInterval(emitDrain, 5); + await served; + clearInterval(pump); + + const streamed = writes.join(''); + expect(writes.length).toBeGreaterThan(1); + expect(streamed).toContain('notifications/progress'); + expect(streamed).toContain('paced'); + }); +}); + +describe('createMcpHandler — close()', () => { + it('aborts in-flight modern exchanges and refuses further requests', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + + const pending = handler.fetch(postRequest(modernToolsCall('park', {}))); + // Give the exchange time to reach the parked handler before tearing down. + await new Promise(resolve => setTimeout(resolve, 50)); + await handler.close(); + + const response = await pending; + expect(response.status).toBe(499); + + await expect(handler.fetch(postRequest(modernToolsCall('echo', { text: 'late' })))).rejects.toThrow(/closed/); + }); + + it('leaves the legacy slot untouched by close() until the handler itself refuses requests', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + await handler.close(); + await expect(handler.fetch(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }))).rejects.toThrow(/closed/); + }); +}); + +/* ------------------------------------------------------------------------ * + * Node face fixtures (duck-typed, no real sockets) + * ------------------------------------------------------------------------ */ + +interface FakeNodeResponse extends NodeServerResponseLike { + statusCode: number; + headers: Record | undefined; +} + +function nodeRequestResponse(body: unknown): { + req: Readable & { + method: string; + url: string; + headers: Record; + auth?: { token: string; clientId: string; scopes: string[] }; + }; + res: FakeNodeResponse; + body: () => Promise; +} { + const payload = body === undefined ? [] : [JSON.stringify(body)]; + const req = Object.assign(Readable.from(payload), { + method: 'POST', + url: '/mcp', + headers: { + host: 'localhost:3000', + 'content-type': 'application/json', + accept: 'application/json, text/event-stream' + } as Record + }); + + const chunks: string[] = []; + let resolveFinished: () => void; + const finished = new Promise(resolve => { + resolveFinished = resolve; + }); + const res: FakeNodeResponse = { + statusCode: 0, + headers: undefined, + writeHead(statusCode: number, headers?: Record) { + this.statusCode = statusCode; + this.headers = headers; + return this; + }, + write(chunk: string | Uint8Array) { + chunks.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)); + return true; + }, + end(chunk?: string | Uint8Array) { + if (chunk !== undefined) { + this.write(chunk); + } + resolveFinished(); + return this; + }, + on() { + return this; + } + }; + + return { + req, + res, + body: async () => { + await finished; + return chunks.join(''); + } + }; +} + +// Type-level pin: a zero-argument factory stays assignable to McpServerFactory unchanged. +const zeroArgFactory = () => new McpServer({ name: 'zero-arg', version: '1.0.0' }); +void createMcpHandler(zeroArgFactory); 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'); + }); +}); diff --git a/packages/server/test/server/createMcpHandlerStatelessLiteral.test.ts b/packages/server/test/server/createMcpHandlerStatelessLiteral.test.ts new file mode 100644 index 0000000000..fff1734cb2 --- /dev/null +++ b/packages/server/test/server/createMcpHandlerStatelessLiteral.test.ts @@ -0,0 +1,83 @@ +/** + * Wire-level continuity twin for the "Unsupported protocol version" rejection, + * exercised through `createMcpHandler(factory, { legacy: 'stateless' })`. + * + * The legacy slot routes 2025-era traffic through the untouched streamable HTTP + * transport, so the rejection site (and therefore the wire bytes deployed + * clients sniff — see streamableHttpUnsupportedVersionLiteral.test.ts for the + * go-sdk substring dependency) is the same one the standalone transport test + * pins. This twin asserts the bytes hold on the sugar path itself: HTTP 400, + * code -32000, and the literal substring `Unsupported protocol version`, with + * the supported-versions suffix derived from `SUPPORTED_PROTOCOL_VERSIONS`. + */ +import { SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; +import { 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'; + +interface JSONRPCErrorBody { + jsonrpc: string; + id: unknown; + error: { code: number; message: string }; +} + +function factory(): McpServer { + const mcpServer = new McpServer({ name: 'literal-twin', version: '1.0.0' }); + mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] + })); + return mcpServer; +} + +function postRequest(body: unknown, headers: Record = {}): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + ...headers + }, + body: JSON.stringify(body) + }); +} + +describe('createMcpHandler legacy:"stateless" — unsupported protocol version wire literal continuity', () => { + it('rejects an unsupported MCP-Protocol-Version header with HTTP 400, code -32000, and the sniffed literal substring', async () => { + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + // The probe header is an unsupported 2025-era version string: that is what a + // deployed 2025 client can actually send. (A 2026-or-later header on a body + // without an envelope claim is a header/body cross-check disagreement and is + // answered by the classifier before legacy serving is reached.) + const response = await handler.fetch( + postRequest({ jsonrpc: '2.0', id: 'tools-1', method: 'tools/list', params: {} }, { 'mcp-protocol-version': '2024-01-01' }) + ); + + expect(response.status).toBe(400); + expect(response.headers.get('content-type')).toContain('application/json'); + + const rawBody = await response.text(); + // The substring deployed clients (go-sdk) sniff must appear verbatim in the wire bytes. + expect(rawBody).toContain('Unsupported protocol version'); + + const body = JSON.parse(rawBody) as JSONRPCErrorBody; + expect(body.jsonrpc).toBe('2.0'); + expect(body.id).toBeNull(); + expect(body.error.code).toBe(-32_000); + expect(body.error.message).toBe( + `Bad Request: Unsupported protocol version: 2024-01-01 (supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')})` + ); + }); + + it('keeps serving supported 2025-era traffic on the same path (the rejection is header-keyed, not blanket)', async () => { + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + const response = await handler.fetch( + postRequest({ jsonrpc: '2.0', id: 'tools-1', method: 'tools/list', params: {} }, { 'mcp-protocol-version': '2025-11-25' }) + ); + expect(response.status).toBe(200); + expect(await response.text()).toContain('"tools"'); + }); +}); diff --git a/packages/server/test/server/discover.test.ts b/packages/server/test/server/discover.test.ts new file mode 100644 index 0000000000..d9806bd1ef --- /dev/null +++ b/packages/server/test/server/discover.test.ts @@ -0,0 +1,224 @@ +/** + * `server/discover` machinery + era-aware supported-version list semantics: + * + * - the handler is installed ONLY on servers that declare modern-era support + * (`eraSupport: 'dual-era' | 'modern'`); default servers keep answering + * -32601 byte-identically to the deployed fleet, and a modern (2026-07-28+) + * revision in the supported-versions list without that declaration is a + * construction-time TypeError + * - the advertisement is modern-only (DV-30) and excludes the + * listChanged/subscribe-class capabilities (A11 rider — until the + * subscriptions/listen milestone lands) + * - counter-offer ordering: with era-aware list semantics in place, a legacy + * initialize can never meet a modern version string at the counter-offer + * site, even when the supported list carries one — the guard that must hold + * BEFORE any LATEST/SUPPORTED constant bump. + * + * The HTTP per-request entry still binds its instances to the modern era + * through the package-internal hook; the `markModern` arm of the harness + * stands in for that path, and the modern-era request shape carries the + * required per-request `_meta` envelope. + */ +import type { DiscoverResult, JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + DiscoverResultSchema, + InitializeResultSchema, + InMemoryTransport, + isJSONRPCErrorResponse, + isJSONRPCResultResponse, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY, + setNegotiatedProtocolVersion, + SUPPORTED_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { discoverAdvertisedCapabilities, Server } from '../../src/server/server.js'; + +const MODERN = '2026-07-28'; +/** A supported list spanning both eras — what the constant becomes after a future bump. */ +const DUAL_ERA_VERSIONS = [MODERN, ...SUPPORTED_PROTOCOL_VERSIONS]; + +async function sendRaw(server: Server, request: JSONRPCRequest, options?: { markModern?: boolean }): Promise { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + if (options?.markModern) { + // Stand-in for the modern-era server entry (instance binding): mark the + // instance as serving the modern era so the era gate admits the method. + setNegotiatedProtocolVersion(server, MODERN); + } + const responsePromise = new Promise(resolve => { + clientTransport.onmessage = msg => resolve(msg); + }); + await clientTransport.start(); + await clientTransport.send(request); + return responsePromise; +} + +/** A wire-true modern discover request: the 2026 era requires the per-request `_meta` envelope. */ +const discoverRequest: JSONRPCRequest = { + jsonrpc: '2.0', + id: 1, + method: 'server/discover', + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + } + } +}; + +const initializeRequest = (requestedVersion: string): JSONRPCRequest => ({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: requestedVersion, capabilities: {}, clientInfo: { name: 'test-client', version: '1.0.0' } } +}); + +describe('server/discover handler gating', () => { + it('a default (legacy-only) server answers server/discover with -32601, byte-identical to the deployed fleet', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + const response = await sendRaw(server, discoverRequest); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + expect(response.error.code).toBe(-32_601); + } + await server.close(); + }); + + it('a server with a modern revision in its supported list serves discover on a modern-era instance', async () => { + const server = new Server( + { name: 'modern-server', version: '2.0.0' }, + { capabilities: { tools: {} }, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era', instructions: 'hello' } + ); + const response = await sendRaw(server, discoverRequest, { markModern: true }); + expect(isJSONRPCResultResponse(response)).toBe(true); + if (isJSONRPCResultResponse(response)) { + const result = DiscoverResultSchema.parse(response.result); + expect(result.supportedVersions).toEqual([MODERN]); + expect(result.serverInfo).toEqual({ name: 'modern-server', version: '2.0.0' }); + expect(result.instructions).toBe('hello'); + } + await server.close(); + }); + + it('a modern-era instance whose supported list carries no modern revision still answers -32601 (handler not installed)', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + const response = await sendRaw(server, discoverRequest, { markModern: true }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + expect(response.error.code).toBe(-32_601); + } + await server.close(); + }); +}); + +describe('discover advertisement constraints', () => { + it('advertises modern-only versions (DV-30): no 2025-era string ever appears in supportedVersions', async () => { + const server = new Server( + { name: 'test', version: '1.0.0' }, + { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' } + ); + const response = await sendRaw(server, discoverRequest, { markModern: true }); + if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); + const result = DiscoverResultSchema.parse(response.result); + expect(result.supportedVersions).toEqual([MODERN]); + for (const version of result.supportedVersions) { + expect(version >= MODERN).toBe(true); + } + await server.close(); + }); + + it('excludes listChanged/subscribe-class capabilities (A11 rider, until subscriptions/listen lands)', async () => { + const server = new Server( + { name: 'test', version: '1.0.0' }, + { + capabilities: { + tools: { listChanged: true }, + prompts: { listChanged: true }, + resources: { listChanged: true, subscribe: true }, + logging: {}, + completions: {} + }, + supportedProtocolVersions: DUAL_ERA_VERSIONS, + eraSupport: 'dual-era' + } + ); + const response = await sendRaw(server, discoverRequest, { markModern: true }); + if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); + const result = DiscoverResultSchema.parse(response.result) as DiscoverResult; + + expect(result.capabilities.tools).toEqual({}); + expect(result.capabilities.prompts).toEqual({}); + expect(result.capabilities.resources).toEqual({}); + expect(result.capabilities.logging).toEqual({}); + expect(result.capabilities.completions).toEqual({}); + expect(JSON.stringify(result.capabilities)).not.toContain('listChanged'); + expect(JSON.stringify(result.capabilities)).not.toContain('subscribe'); + + await server.close(); + }); + + it('discoverAdvertisedCapabilities is pure and leaves the initialize advertisement untouched', async () => { + const capabilities = { tools: { listChanged: true }, resources: { subscribe: true, listChanged: true } }; + const stripped = discoverAdvertisedCapabilities(capabilities); + expect(stripped).toEqual({ tools: {}, resources: {} }); + // No mutation of the input. + expect(capabilities).toEqual({ tools: { listChanged: true }, resources: { subscribe: true, listChanged: true } }); + + // The legacy initialize advertisement still carries the full capability set. + const server = new Server( + { name: 'test', version: '1.0.0' }, + { capabilities, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' } + ); + const response = await sendRaw(server, initializeRequest(LATEST_PROTOCOL_VERSION)); + if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); + const result = InitializeResultSchema.parse(response.result); + expect(result.capabilities.tools).toEqual({ listChanged: true }); + expect(result.capabilities.resources).toEqual({ subscribe: true, listChanged: true }); + await server.close(); + }); +}); + +describe('era-aware counter-offer ordering (the guard that precedes any constant bump)', () => { + it('an unknown requested version is countered with the latest LEGACY version even when the list carries a modern one', async () => { + const server = new Server( + { name: 'test', version: '1.0.0' }, + { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' } + ); + const response = await sendRaw(server, initializeRequest('1999-01-01')); + if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); + const result = InitializeResultSchema.parse(response.result); + // supportedProtocolVersions[0] is the modern revision here — the + // counter-offer must NOT be it: a fallback initialize never meets a + // leaked 2026 string at this site. + expect(result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + expect(result.protocolVersion).not.toBe(MODERN); + await server.close(); + }); + + it('an initialize REQUESTING the modern revision is also answered with the latest legacy version (initialize never negotiates a modern era)', async () => { + const server = new Server( + { name: 'test', version: '1.0.0' }, + { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' } + ); + const response = await sendRaw(server, initializeRequest(MODERN)); + if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); + const result = InitializeResultSchema.parse(response.result); + expect(result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + expect(server.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + await server.close(); + }); + + it('default-list behavior is byte-identical: the legacy subset IS the whole list today', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + const response = await sendRaw(server, initializeRequest('1999-01-01')); + if (!isJSONRPCResultResponse(response)) throw new Error('expected result'); + const result = InitializeResultSchema.parse(response.result); + expect(result.protocolVersion).toBe(SUPPORTED_PROTOCOL_VERSIONS[0]); + await server.close(); + }); +}); diff --git a/packages/server/test/server/dualEraServing.test.ts b/packages/server/test/server/dualEraServing.test.ts new file mode 100644 index 0000000000..32d9b6dd53 --- /dev/null +++ b/packages/server/test/server/dualEraServing.test.ts @@ -0,0 +1,384 @@ +/** + * Long-lived dual-era serving (`eraSupport: 'dual-era'`) on one connection: + * + * - the legacy vertical (initialize → tools/list → tools/call) is served + * exactly as a 2025 server serves it (no 2026 wire fields anywhere); + * - the modern vertical (server/discover → tools/list → tools/call, every + * request carrying the per-request `_meta` envelope) is served on the + * 2026 era on the SAME connection; + * - the long-lived era gate: a message classified into the legacy era asking + * for `server/discover`, `subscriptions/listen`, or any 2026-only method is + * answered with a plain −32601 carrying ZERO 2026 vocabulary in message or + * data (the dedicated leak test — the gate is not structural on a long-lived + * instance, which hosts both registries); the modern-direction denial of + * legacy-only methods mirrors it. + * - Q10-L2: a hand-constructed server with the default `eraSupport` serves a + * scripted 2025 session with today's exact result shapes and zero 2026 + * vocabulary on the wire. + */ +import type { JSONRPCErrorResponse, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + InMemoryTransport, + isJSONRPCErrorResponse, + isJSONRPCResultResponse, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { McpServer } from '../../src/server/mcp.js'; + +const MODERN = '2026-07-28'; + +/** + * 2026-era vocabulary that must never leak into a legacy-direction response. + * The gate answers with the same plain `-32601` a 2025 server answers for an + * unknown method — nothing in message or data may reveal that the instance + * also hosts the modern era. + */ +const FORBIDDEN_2026_VOCABULARY = [ + '2026', + 'discover', + 'envelope', + 'modern', + 'dual', + 'era', + '_meta', + 'io.modelcontextprotocol', + 'resultType', + 'protocolVersion', + 'protocol version', + 'subscription' +]; + +/** The 2026-only request methods the era gate must hide from legacy-era traffic. */ +const MODERN_ONLY_METHODS = ['server/discover', 'subscriptions/listen']; + +/** + * Legacy-only methods whose modern-direction denial mirrors the gate. + * (`initialize` is not in this list only because it has its own dedicated + * coverage below: an `initialize` carrying a valid modern envelope claim is + * classified by the claim — the claim wins over the legacy-handshake rule — + * and is denied with the same plain −32601.) + */ +const LEGACY_ONLY_METHODS = ['ping', 'logging/setLevel', 'resources/subscribe']; + +const envelope = (overrides?: Record) => ({ + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'modern-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {}, + ...overrides +}); + +function buildServer(options?: { eraSupport?: 'legacy' | 'dual-era' | 'modern' }) { + const server = new McpServer( + { name: 'dual-era-test-server', version: '1.0.0' }, + { + capabilities: { tools: {} }, + instructions: 'test instructions', + ...(options?.eraSupport ? { eraSupport: options.eraSupport } : {}) + } + ); + server.registerTool('echo', { description: 'Echoes the input text', inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + return server; +} + +async function wire(server: McpServer) { + const [peerTx, serverTx] = InMemoryTransport.createLinkedPair(); + const inbound: JSONRPCMessage[] = []; + const waiters = new Map void>(); + peerTx.onmessage = message => { + inbound.push(message); + const id = (message as { id?: string | number }).id; + const waiter = id === undefined ? undefined : waiters.get(id); + if (id !== undefined && waiter) { + waiters.delete(id); + waiter(message); + } + }; + await server.connect(serverTx); + await peerTx.start(); + + const request = (message: JSONRPCRequest): Promise => + new Promise(resolve => { + waiters.set(message.id, resolve); + void peerTx.send(message); + }); + const notify = (message: JSONRPCNotification): Promise => peerTx.send(message); + return { request, notify, inbound, close: () => server.close() }; +} + +const initializeRequest = (id: number): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'initialize', + params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'legacy-client', version: '1.0.0' } } +}); + +describe('dual-era serving on one long-lived connection', () => { + it('serves the legacy vertical and the modern vertical on the same connection, each on its own era', async () => { + const server = buildServer({ eraSupport: 'dual-era' }); + const { request, notify, close } = await wire(server); + + // --- Legacy vertical: initialize → initialized → tools/list → tools/call. + const init = await request(initializeRequest(1)); + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + expect(JSON.stringify(init)).not.toContain('resultType'); + expect(JSON.stringify(init)).not.toContain('2026'); + } + await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + const legacyList = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(legacyList)).toBe(true); + if (isJSONRPCResultResponse(legacyList)) { + expect((legacyList.result as { tools: Array<{ name: string }> }).tools.map(tool => tool.name)).toEqual(['echo']); + expect(JSON.stringify(legacyList)).not.toContain('resultType'); + } + + const legacyCall = await request({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'legacy leg' } } + }); + expect(isJSONRPCResultResponse(legacyCall)).toBe(true); + if (isJSONRPCResultResponse(legacyCall)) { + expect((legacyCall.result as { content: unknown[] }).content).toEqual([{ type: 'text', text: 'legacy leg' }]); + expect(JSON.stringify(legacyCall)).not.toContain('resultType'); + } + + // --- Modern vertical on the SAME connection: discover → list → call, + // every request carrying the per-request envelope. + const discover = await request({ jsonrpc: '2.0', id: 4, method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(discover)).toBe(true); + if (isJSONRPCResultResponse(discover)) { + const result = discover.result as { supportedVersions?: string[]; resultType?: string }; + expect(result.supportedVersions).toEqual([MODERN]); + expect(result.resultType).toBe('complete'); + } + + const modernList = await request({ jsonrpc: '2.0', id: 5, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(modernList)).toBe(true); + if (isJSONRPCResultResponse(modernList)) { + const result = modernList.result as { tools: Array<{ name: string }>; resultType?: string }; + expect(result.tools.map(tool => tool.name)).toEqual(['echo']); + expect(result.resultType).toBe('complete'); + } + + const modernCall = await request({ + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'modern leg' }, _meta: envelope() } + }); + expect(isJSONRPCResultResponse(modernCall)).toBe(true); + if (isJSONRPCResultResponse(modernCall)) { + const result = modernCall.result as { content: unknown[]; resultType?: string }; + expect(result.content).toEqual([{ type: 'text', text: 'modern leg' }]); + expect(result.resultType).toBe('complete'); + } + + // The legacy leg is unaffected by the modern exchanges that ran in between. + const legacyAgain = await request({ jsonrpc: '2.0', id: 7, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(legacyAgain)).toBe(true); + expect(JSON.stringify(legacyAgain)).not.toContain('resultType'); + + await close(); + }); + + it('the modern era is reachable without any prior legacy handshake (envelope-first connection)', async () => { + const server = buildServer({ eraSupport: 'dual-era' }); + const { request, close } = await wire(server); + + const discover = await request({ jsonrpc: '2.0', id: 1, method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(discover)).toBe(true); + await close(); + }); +}); + +describe('long-lived era gate + zero-2026-vocabulary leak test', () => { + it('a legacy-classified request for any 2026-only method answers a plain −32601 with zero 2026 vocabulary in message or data', async () => { + const server = buildServer({ eraSupport: 'dual-era' }); + const { request, close } = await wire(server); + + // Establish the legacy leg first — the gate must hold on a connection + // that is actively serving 2025 traffic. + const init = await request(initializeRequest(1)); + expect(isJSONRPCResultResponse(init)).toBe(true); + + let id = 10; + for (const method of MODERN_ONLY_METHODS) { + // No envelope claim ⇒ classified legacy ⇒ the modern registry must be invisible. + const response = await request({ jsonrpc: '2.0', id: (id += 1), method, params: {} }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + const error = (response as JSONRPCErrorResponse).error; + expect(error.code).toBe(-32_601); + expect(error.message).toBe('Method not found'); + expect(error.data).toBeUndefined(); + + const serialized = JSON.stringify({ error, id: null }); + for (const term of FORBIDDEN_2026_VOCABULARY) { + expect(serialized.toLowerCase()).not.toContain(term.toLowerCase()); + } + } + await close(); + }); + + it('the modern-direction denial mirrors it: a modern-classified request for a legacy-only method answers −32601', async () => { + const server = buildServer({ eraSupport: 'dual-era' }); + const { request, close } = await wire(server); + + let id = 20; + for (const method of LEGACY_ONLY_METHODS) { + const response = await request({ jsonrpc: '2.0', id: (id += 1), method, params: { _meta: envelope() } }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + const error = (response as JSONRPCErrorResponse).error; + expect(error.code).toBe(-32_601); + expect(error.message).toBe('Method not found'); + } + await close(); + }); +}); + +describe('enveloped initialize on a dual-era instance (a valid modern claim wins over the legacy-handshake rule)', () => { + it('an initialize carrying a valid modern envelope claim answers a plain −32601 and is never served by the legacy handshake', async () => { + const server = buildServer({ eraSupport: 'dual-era' }); + const { request, close } = await wire(server); + + const response = await request({ jsonrpc: '2.0', id: 30, method: 'initialize', params: { _meta: envelope() } }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + const error = (response as JSONRPCErrorResponse).error; + expect(error.code).toBe(-32_601); + expect(error.message).toBe('Method not found'); + expect(error.data).toBeUndefined(); + + // Nothing beyond the normal method-not-found shape leaks 2026 vocabulary. + const serialized = JSON.stringify({ error, id: null }); + for (const term of FORBIDDEN_2026_VOCABULARY) { + expect(serialized.toLowerCase()).not.toContain(term.toLowerCase()); + } + + // The legacy initialize path never ran: the initialize-scoped accessors stay unset. + expect(server.server.getNegotiatedProtocolVersion()).toBeUndefined(); + expect(server.server.getClientVersion()).toBeUndefined(); + + // An envelope-less initialize on the same connection keeps today's behavior: + // the legacy handshake is served exactly as before, with zero 2026 vocabulary. + const init = await request(initializeRequest(31)); + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + expect(JSON.stringify(init)).not.toContain('resultType'); + expect(JSON.stringify(init)).not.toContain('2026'); + } + await close(); + }); + + it('an initialize with a malformed envelope claim keeps the legacy handshake', async () => { + const server = buildServer({ eraSupport: 'dual-era' }); + const { request, close } = await wire(server); + + // The claim key is present but the envelope is incomplete — never a + // silent flip to the modern era; the legacy handshake serves it as before. + const response = await request({ + jsonrpc: '2.0', + id: 40, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'legacy-client', version: '1.0.0' }, + _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN } + } + }); + expect(isJSONRPCResultResponse(response)).toBe(true); + if (isJSONRPCResultResponse(response)) { + expect((response.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + } + await close(); + }); + + it('an initialize whose valid envelope claim names a pre-2026 revision keeps the legacy handshake', async () => { + const server = buildServer({ eraSupport: 'dual-era' }); + const { request, close } = await wire(server); + + const response = await request({ + jsonrpc: '2.0', + id: 50, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'legacy-client', version: '1.0.0' }, + _meta: envelope({ [PROTOCOL_VERSION_META_KEY]: '2025-06-18' }) + } + }); + expect(isJSONRPCResultResponse(response)).toBe(true); + if (isJSONRPCResultResponse(response)) { + expect((response.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + } + await close(); + }); +}); + +describe('Q10-L2: a hand-constructed server with the default eraSupport on 2025 traffic', () => { + it('serves a scripted 2025 session with the exact 2025 shapes and zero 2026 vocabulary on the wire', async () => { + const server = buildServer(); + const { request, notify, inbound, close } = await wire(server); + + const init = await request(initializeRequest(1)); + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect(init.result).toEqual({ + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 'dual-era-test-server', version: '1.0.0' }, + instructions: 'test instructions' + }); + } + await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + const list = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(list)).toBe(true); + if (isJSONRPCResultResponse(list)) { + const tools = (list.result as { tools: Array> }).tools; + expect(tools).toHaveLength(1); + expect(tools[0]).toMatchObject({ name: 'echo', description: 'Echoes the input text' }); + expect(Object.keys(list.result as Record).sort()).toEqual(['tools']); + } + + const call = await request({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'echo', arguments: { text: 'hi' } } }); + expect(isJSONRPCResultResponse(call)).toBe(true); + if (isJSONRPCResultResponse(call)) { + expect(call.result).toEqual({ content: [{ type: 'text', text: 'hi' }] }); + } + + const ping = await request({ jsonrpc: '2.0', id: 4, method: 'ping' }); + expect(isJSONRPCResultResponse(ping)).toBe(true); + if (isJSONRPCResultResponse(ping)) { + expect(ping.result).toEqual({}); + } + + // A default instance keeps answering server/discover with -32601, byte-identical to the deployed fleet. + const discover = await request({ jsonrpc: '2.0', id: 5, method: 'server/discover', params: {} }); + expect(isJSONRPCErrorResponse(discover)).toBe(true); + if (isJSONRPCErrorResponse(discover)) { + expect(discover.error).toEqual({ code: -32_601, message: 'Method not found' }); + } + + // Nothing the server wrote on this 2025 session carries 2026 wire vocabulary. + const wireBytes = JSON.stringify(inbound); + expect(wireBytes).not.toContain('resultType'); + expect(wireBytes).not.toContain('2026'); + expect(wireBytes).not.toContain('io.modelcontextprotocol/'); + + await close(); + }); +}); diff --git a/packages/server/test/server/eraParityErrorShapes.test.ts b/packages/server/test/server/eraParityErrorShapes.test.ts new file mode 100644 index 0000000000..7e80616a0b --- /dev/null +++ b/packages/server/test/server/eraParityErrorShapes.test.ts @@ -0,0 +1,246 @@ +/** + * Era-parity error shapes: the same malformed input produces the same + * JSON-RPC error shape on the 2025-era (session-oriented streamable HTTP + * transport) and on the modern per-request path — modulo an explicitly + * enumerated table of era-mandated differences. Anything outside that table + * is a parity regression. + */ +import type { CallToolResult, JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core'; +import { + classifyInboundRequest, + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + ProtocolError, + setNegotiatedProtocolVersion +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport.js'; +import { Server } from '../../src/server/server.js'; +import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp.js'; + +const MODERN_REVISION = '2026-07-28'; +const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION }; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'parity-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +/** + * Era-mandated differences between the two serving paths for the inputs + * exercised below. Everything else must be identical. + * + * - HTTP status: pre-handler rejections are status-mapped on the modern + * per-request path (e.g. method-not-found answers HTTP 404), while the + * 2025-era transport always carries dispatch errors in-band on HTTP 200. + * Asserted literally on both legs by the unknown-method test below. + * - The modern era requires the per-request `_meta` envelope on every + * request; the inputs below carry it on the modern leg only, where it is + * wire-level bookkeeping that never reaches handlers. + * - The malformed-body divergences enumerated in {@link KNOWN_EDGE_DIVERGENCES}, + * asserted literally on both legs by the divergence-table test below. + */ + +/** + * Known, deliberate divergences between what the deployed 2025-era streamable + * HTTP transport answers for a malformed POST body and what the modern edge + * (the inbound classifier) answers for the same body. + * + * These are hand-written literals — NOT derived from the observed behavior of + * either leg — so a behavior change on EITHER side fails the assertions below + * and forces this enumeration (and the matching cell-sheet rationales in the + * core package) to be revisited. + */ +const KNOWN_EDGE_DIVERGENCES: ReadonlyArray<{ + divergence: string; + /** The parsed POST body both legs receive. */ + body: unknown; + /** What the deployed 2025-era transport answers today. */ + legacy: { httpStatus: number; code?: number }; + /** What the modern edge (the inbound classifier) answers. */ + modernEdge: { httpStatus: number; code: number }; + rationale: string; +}> = [ + { + divergence: 'parsed-but-not-json-rpc-single-object', + body: { hello: 'world' }, + legacy: { httpStatus: 400, code: -32_700 }, + modernEdge: { httpStatus: 400, code: -32_600 }, + rationale: + 'The deployed transport answers a parse error (-32700) for a parsed body that is not a JSON-RPC message; the modern ' + + 'edge answers the JSON-RPC-correct invalid request (-32600).' + }, + { + divergence: 'empty-batch', + body: [], + legacy: { httpStatus: 202 }, + modernEdge: { httpStatus: 400, code: -32_600 }, + rationale: + 'The deployed transport accepts an empty batch as containing only notifications (202, no body); the modern edge ' + + 'rejects it as an invalid request.' + } +]; + +interface LegError { + status: number; + error: { code: number; message: string; data?: unknown }; +} + +function buildServer(): Server { + const server = new Server({ name: 'parity', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/call', async (): Promise => ({ content: [{ type: 'text', text: 'ok' }] })); + server.setRequestHandler('app/fail', { params: z.looseObject({}) }, async () => { + throw new ProtocolError(-32_002, 'resource missing'); + }); + return server; +} + +/** + * Posts an arbitrary (possibly malformed) body to the deployed 2025-era + * transport and returns the raw HTTP outcome — unlike {@link legacyLeg}, it + * does not assume the response carries a JSON error body (a 202 has none). + */ +async function legacyRawLeg(body: unknown): Promise<{ status: number; error?: LegError['error'] }> { + const server = buildServer(); + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + await server.connect(transport); + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(body) + }) + ); + const text = await response.text(); + await server.close(); + return { + status: response.status, + ...(text.length > 0 && { error: (JSON.parse(text) as { error: LegError['error'] }).error }) + }; +} + +async function legacyLeg(body: Record): Promise { + const server = buildServer(); + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + await server.connect(transport); + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(body) + }) + ); + const parsed = (await response.json()) as { error: LegError['error'] }; + await server.close(); + return { status: response.status, error: parsed.error }; +} + +async function modernLeg(body: Record): Promise { + const server = buildServer(); + setNegotiatedProtocolVersion(server, MODERN_REVISION); + const transport = new PerRequestHTTPServerTransport({ classification: MODERN }); + await server.connect(transport); + const enveloped = { + ...body, + params: { ...(body['params'] as Record | undefined), _meta: ENVELOPE } + }; + const response = await transport.handleMessage(enveloped as unknown as JSONRPCRequest); + const parsed = (await response.json()) as { error: LegError['error'] }; + await server.close(); + return { status: response.status, error: parsed.error }; +} + +describe('era-parity error shapes', () => { + it.each(KNOWN_EDGE_DIVERGENCES)( + 'known divergence "$divergence": both legs answer exactly what the table enumerates', + async ({ body, legacy, modernEdge }) => { + // Legacy leg: the deployed 2025-era transport, exercised over HTTP. + const legacyActual = await legacyRawLeg(body); + expect(legacyActual.status).toBe(legacy.httpStatus); + if (legacy.code !== undefined) { + expect(legacyActual.error?.code).toBe(legacy.code); + } else { + expect(legacyActual.error).toBeUndefined(); + } + + // Modern leg: the per-request path answers these bodies at the + // edge (the inbound classifier) — they never reach a transport. + const modernActual = classifyInboundRequest({ httpMethod: 'POST', body }); + expect(modernActual.kind).toBe('reject'); + if (modernActual.kind !== 'reject') return; + expect(modernActual.httpStatus).toBe(modernEdge.httpStatus); + expect(modernActual.code).toBe(modernEdge.code); + } + ); + + it('an unknown method produces the same JSON-RPC error on both legs (status mapping is the enumerated difference)', async () => { + const input = { jsonrpc: '2.0', id: 11, method: 'definitely/unknown', params: {} }; + const legacy = await legacyLeg(input); + const modern = await modernLeg(input); + + expect(legacy.error.code).toBe(-32_601); + expect(modern.error.code).toBe(legacy.error.code); + expect(modern.error.message).toBe(legacy.error.message); + expect(modern.error.data).toEqual(legacy.error.data); + + // Enumerated difference: http-status-mapping. + expect(legacy.status).toBe(200); + expect(modern.status).toBe(404); + }); + + it('a handler-thrown protocol error produces the same in-band JSON-RPC error on both legs', async () => { + const input = { jsonrpc: '2.0', id: 12, method: 'app/fail', params: {} }; + const legacy = await legacyLeg(input); + const modern = await modernLeg(input); + + expect(legacy.status).toBe(200); + expect(modern.status).toBe(200); + expect(legacy.error).toMatchObject({ code: -32_002, message: 'resource missing' }); + expect(modern.error).toEqual(legacy.error); + }); + + it('a handler-level invalid-params rejection produces the same in-band error code on both legs', async () => { + const failingParams = new Server({ name: 'parity-params', version: '1.0.0' }, { capabilities: {} }); + // Same registration on both legs: a custom method with a params schema + // the input does not satisfy. + const register = (server: Server) => + server.setRequestHandler('app/strict', { params: z.object({ value: z.string() }) }, async params => ({ ok: params.value })); + register(failingParams); + + const legacyTransport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + await failingParams.connect(legacyTransport); + const legacyResponse = await legacyTransport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 13, method: 'app/strict', params: { value: 7 } }) + }) + ); + const legacyBody = (await legacyResponse.json()) as { error: { code: number } }; + await failingParams.close(); + + const modernServer = new Server({ name: 'parity-params', version: '1.0.0' }, { capabilities: {} }); + register(modernServer); + setNegotiatedProtocolVersion(modernServer, MODERN_REVISION); + const modernTransport = new PerRequestHTTPServerTransport({ classification: MODERN }); + await modernServer.connect(modernTransport); + const modernResponse = await modernTransport.handleMessage({ + jsonrpc: '2.0', + id: 13, + method: 'app/strict', + params: { value: 7, _meta: ENVELOPE } + } as JSONRPCRequest); + const modernBody = (await modernResponse.json()) as { error: { code: number } }; + await modernServer.close(); + + expect(legacyBody.error.code).toBe(-32_602); + expect(modernBody.error.code).toBe(legacyBody.error.code); + // Handler-level invalid params stays in-band on both legs. + expect(legacyResponse.status).toBe(200); + expect(modernResponse.status).toBe(200); + }); +}); diff --git a/packages/server/test/server/eraSupport.test.ts b/packages/server/test/server/eraSupport.test.ts new file mode 100644 index 0000000000..78ff050cb7 --- /dev/null +++ b/packages/server/test/server/eraSupport.test.ts @@ -0,0 +1,390 @@ +/** + * `ServerOptions.eraSupport` — the stdio/long-lived-connection era opt-in: + * + * - default `'legacy'` for hand-constructed `Server`/`McpServer`: nothing + * 2026-era is registered or advertised, and a modern revision in + * `supportedProtocolVersions` without the declaration is a construction-time + * `TypeError` (never a silent behavior change). + * - `'dual-era'`: `server/discover` registered without any instance binding, + * modern revisions advertised, both eras served per message. + * - `'modern'`: strict 2026-only — envelope-less requests (including + * `initialize`) answer the unsupported-protocol-version error with the + * supported list; legacy-classified notifications are dropped. + * - TS-01 directionality: a modern-bound instance cannot emit server→client + * wire requests (typed local error); a dual-era instance serving the legacy + * leg still can, while a handler serving a 2026-classified request gets the + * same typed error from the ctx-related request path. + */ +import type { JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + InMemoryTransport, + isJSONRPCErrorResponse, + isJSONRPCResultResponse, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY, + SdkError, + SdkErrorCode, + SUPPORTED_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { McpServer } from '../../src/server/mcp.js'; +import { Server } from '../../src/server/server.js'; + +const MODERN = '2026-07-28'; +const DUAL_ERA_VERSIONS = [MODERN, ...SUPPORTED_PROTOCOL_VERSIONS]; + +const envelope = (overrides?: Record) => ({ + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'era-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {}, + ...overrides +}); + +const initializeRequest = (id: number, requestedVersion = LATEST_PROTOCOL_VERSION): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'initialize', + params: { + protocolVersion: requestedVersion, + capabilities: { sampling: {} }, + clientInfo: { name: 'legacy-client', version: '1.0.0' } + } +}); + +interface Connectable { + connect(transport: InstanceType): Promise; + close(): Promise; +} + +/** Wires a server to one long-lived in-memory connection and returns request/notify drivers. */ +async function wireServer(server: Connectable) { + const [peerTx, serverTx] = InMemoryTransport.createLinkedPair(); + const inbound: JSONRPCMessage[] = []; + const waiters = new Map void>(); + peerTx.onmessage = message => { + inbound.push(message); + const id = (message as { id?: string | number }).id; + const waiter = id === undefined ? undefined : waiters.get(id); + if (id !== undefined && waiter) { + waiters.delete(id); + waiter(message); + } + }; + await server.connect(serverTx); + await peerTx.start(); + + const request = (message: JSONRPCRequest): Promise => + new Promise(resolve => { + waiters.set(message.id, resolve); + void peerTx.send(message); + }); + const notify = (message: JSONRPCNotification): Promise => peerTx.send(message); + const flush = () => new Promise(resolve => setTimeout(resolve, 10)); + return { request, notify, flush, inbound, peerTx, close: () => server.close() }; +} + +describe('construction-time guard (default eraSupport is legacy)', () => { + it('throws a TypeError when supportedProtocolVersions carries a modern revision on a default instance', () => { + expect(() => new Server({ name: 't', version: '1' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS })).toThrow( + TypeError + ); + expect(() => new Server({ name: 't', version: '1' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS })).toThrow( + /eraSupport/ + ); + }); + + it('throws for McpServer too (options are forwarded)', () => { + expect(() => new McpServer({ name: 't', version: '1' }, { supportedProtocolVersions: [MODERN] })).toThrow(TypeError); + }); + + it('does not throw when the modern revision is accompanied by a dual-era or modern declaration', () => { + expect( + () => + new Server( + { name: 't', version: '1' }, + { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' } + ) + ).not.toThrow(); + expect( + () => new Server({ name: 't', version: '1' }, { capabilities: {}, supportedProtocolVersions: [MODERN], eraSupport: 'modern' }) + ).not.toThrow(); + }); + + it('a default legacy-only construction stays exactly as before (no throw, no discover handler)', async () => { + const server = new Server({ name: 't', version: '1' }, { capabilities: {} }); + const { request, close } = await wireServer(server); + const response = await request({ jsonrpc: '2.0', id: 1, method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + expect(response.error.code).toBe(-32_601); + } + await close(); + }); +}); + +describe("DV-30: server/discover is registered only when eraSupport !== 'legacy'", () => { + it('a dual-era server serves discover with no instance binding and advertises only modern revisions', async () => { + const server = new Server({ name: 'dual', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'dual-era' }); + const { request, close } = await wireServer(server); + + const response = await request({ jsonrpc: '2.0', id: 1, method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(response)).toBe(true); + if (isJSONRPCResultResponse(response)) { + const result = response.result as { supportedVersions?: string[]; resultType?: string }; + expect(result.supportedVersions).toEqual([MODERN]); + // Served on the modern era: the wire result carries the 2026 result discriminator. + expect(result.resultType).toBe('complete'); + } + await close(); + }); + + it('the served modern revisions are added to the supported list without mutating the shared default constant', () => { + const before = [...SUPPORTED_PROTOCOL_VERSIONS]; + const server = new Server({ name: 'dual', version: '1' }, { capabilities: {}, eraSupport: 'dual-era' }); + expect(SUPPORTED_PROTOCOL_VERSIONS).toEqual(before); + expect(server).toBeDefined(); + }); +}); + +describe("DV-31: strict 'modern' on a long-lived connection", () => { + async function wireModernServer() { + const server = new Server({ name: 'strict', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'modern' }); + server.setRequestHandler('tools/list', () => ({ tools: [] })); + return { server, ...(await wireServer(server)) }; + } + + it('an envelope-less non-initialize request answers −32004 with the supported list', async () => { + const { request, close } = await wireModernServer(); + const response = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + // The envelope-less request on a modern-only instance answers the + // unsupported-protocol-version error with the supported list (the + // HTTP entry's header/body mismatch cells use −32001 instead; there + // is no header layer on a long-lived connection). + expect(response.error.code).toBe(-32_004); + const data = response.error.data as { supported?: string[]; requested?: string }; + // The strict instance serves only modern revisions, so the supported + // list it advertises names only those (never the legacy defaults). + expect(data.supported).toEqual([MODERN]); + expect(typeof data.requested).toBe('string'); + } + await close(); + }); + + it('an envelope-less initialize answers −32004 with the supported list (never a legacy handshake)', async () => { + const { request, close } = await wireModernServer(); + const response = await request(initializeRequest(2)); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + expect(response.error.code).toBe(-32_004); + expect((response.error.data as { supported?: string[] }).supported).toEqual([MODERN]); + expect((response.error.data as { requested?: string }).requested).toBe(LATEST_PROTOCOL_VERSION); + } + await close(); + }); + + it('an initialize carrying a valid modern envelope claim answers a plain −32601 (the claim wins over the legacy-handshake rule)', async () => { + const { request, close } = await wireModernServer(); + const response = await request({ jsonrpc: '2.0', id: 5, method: 'initialize', params: { _meta: envelope() } }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + // Classified by its valid modern claim, the request is served on the + // modern era, where `initialize` is answered like every other method + // that era does not define — never with the version error reserved + // for envelope-less requests. + expect(response.error.code).toBe(-32_601); + expect(response.error.message).toBe('Method not found'); + expect(response.error.data).toBeUndefined(); + } + await close(); + }); + + it('a legacy-classified notification is dropped without a response', async () => { + const { notify, flush, inbound, close } = await wireModernServer(); + await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); + await flush(); + expect(inbound).toHaveLength(0); + await close(); + }); + + it('an enveloped modern request is served', async () => { + const { request, close } = await wireModernServer(); + const response = await request({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(response)).toBe(true); + if (isJSONRPCResultResponse(response)) { + expect((response.result as { tools?: unknown[] }).tools).toEqual([]); + expect((response.result as { resultType?: string }).resultType).toBe('complete'); + } + await close(); + }); + + it('server/discover advertises only modern revisions', async () => { + const { request, close } = await wireModernServer(); + const response = await request({ jsonrpc: '2.0', id: 4, method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(response)).toBe(true); + if (isJSONRPCResultResponse(response)) { + expect((response.result as { supportedVersions?: string[] }).supportedVersions).toEqual([MODERN]); + } + await close(); + }); + + it('a mixed legacy+modern supported list is reduced to its modern subset at construction', async () => { + const server = new Server( + { name: 'strict', version: '1' }, + { capabilities: { tools: {} }, supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'modern' } + ); + const { request, close } = await wireServer(server); + + // The unsupported-protocol-version handoff names only the modern + // revisions: the legacy entries the consumer passed are never served + // by a strict instance, so they are not advertised either. + const response = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + expect((response.error.data as { supported?: string[] }).supported).toEqual([MODERN]); + } + await close(); + }); +}); + +describe('TS-01 directionality (era-keyed direction enforcement)', () => { + it('a strict-modern instance cannot emit server→client wire requests: typed local error, nothing reaches the transport', async () => { + const server = new Server({ name: 'strict', version: '1' }, { capabilities: {}, eraSupport: 'modern' }); + const { inbound, flush, close } = await wireServer(server); + + await expect( + server.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], maxTokens: 1 }) + ).rejects.toThrow(/not supported by the negotiated protocol version/); + await flush(); + expect(inbound).toHaveLength(0); + await close(); + }); + + it('a dual-era instance serving the legacy leg still emits server→client requests (permitted per the message era)', async () => { + const server = new Server({ name: 'dual', version: '1' }, { capabilities: {}, eraSupport: 'dual-era' }); + const { request, inbound, flush, close } = await wireServer(server); + + // Legacy leg: the 2025 client initializes and declares sampling support. + const init = await request(initializeRequest(1)); + expect(isJSONRPCResultResponse(init)).toBe(true); + + // The server-initiated sampling request is legal on the legacy leg and reaches the wire. + const pending = server.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], maxTokens: 1 }); + pending.catch(() => { + // The peer never answers; the request is torn down with the connection below. + }); + await flush(); + expect(inbound.some(message => (message as JSONRPCRequest).method === 'sampling/createMessage')).toBe(true); + await close(); + }); + + it('a handler serving a modern-classified request gets the typed error from the ctx sampling helper; nothing reaches the transport', async () => { + const server = new Server({ name: 'dual', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'dual-era' }); + let captured: unknown; + server.setRequestHandler('tools/list', async (_request, ctx) => { + try { + await ctx.mcpReq.requestSampling({ messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], maxTokens: 1 }); + } catch (error) { + captured = error; + } + return { tools: [] }; + }); + const { request, inbound, flush, close } = await wireServer(server); + + const response = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(response)).toBe(true); + await flush(); + + expect(captured).toBeInstanceOf(SdkError); + expect((captured as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + expect((captured as SdkError).message).toMatch(/not available on protocol revision 2026-07-28/); + expect(inbound.some(message => (message as JSONRPCRequest).method === 'sampling/createMessage')).toBe(false); + await close(); + }); + + it('a raw ctx server→client request send while serving a modern-classified request is rejected the same way', async () => { + const server = new Server({ name: 'dual', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'dual-era' }); + let captured: unknown; + server.setRequestHandler('tools/list', async (_request, ctx) => { + try { + await ctx.mcpReq.send({ method: 'roots/list' }); + } catch (error) { + captured = error; + } + return { tools: [] }; + }); + const { request, inbound, flush, close } = await wireServer(server); + + const response = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(response)).toBe(true); + await flush(); + + expect(captured).toBeInstanceOf(SdkError); + expect((captured as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + expect(inbound.some(message => (message as JSONRPCRequest).method === 'roots/list')).toBe(false); + await close(); + }); + + it('the same ctx sampling helper on a legacy-classified request still reaches the wire (permitted per the message era)', async () => { + const server = new Server({ name: 'dual', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'dual-era' }); + server.setRequestHandler('tools/list', (_request, ctx) => { + const pending = ctx.mcpReq.requestSampling({ + messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], + maxTokens: 1 + }); + pending.catch(() => { + // The peer never answers; the request is torn down with the connection below. + }); + return { tools: [] }; + }); + const { request, inbound, flush, close } = await wireServer(server); + + // Legacy leg: the 2025 client initializes and declares sampling support. + const init = await request(initializeRequest(1)); + expect(isJSONRPCResultResponse(init)).toBe(true); + + const response = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(response)).toBe(true); + await flush(); + + expect(inbound.some(message => (message as JSONRPCRequest).method === 'sampling/createMessage')).toBe(true); + await close(); + }); +}); + +describe('accessor split on long-lived dual-era instances', () => { + it('getClientCapabilities/getClientVersion/getNegotiatedProtocolVersion keep initialize-scoped semantics; modern envelopes never backfill them', async () => { + const server = new Server({ name: 'dual', version: '1' }, { capabilities: { tools: {} }, eraSupport: 'dual-era' }); + server.setRequestHandler('tools/list', () => ({ tools: [] })); + const { request, close } = await wireServer(server); + + // Legacy handshake populates the initialize-scoped accessors. + const init = await request(initializeRequest(1)); + expect(isJSONRPCResultResponse(init)).toBe(true); + expect(server.getClientVersion()).toEqual({ name: 'legacy-client', version: '1.0.0' }); + expect(server.getClientCapabilities()).toEqual({ sampling: {} }); + expect(server.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + + // A modern message carrying a different client identity in its envelope + // is served, but never backfills the instance-level accessors (per-message + // identity is read from the per-request context, not instance state). + const modern = await request({ + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: { + _meta: envelope({ [CLIENT_INFO_META_KEY]: { name: 'modern-client', version: '9.9.9' } }) + } + }); + expect(isJSONRPCResultResponse(modern)).toBe(true); + expect(server.getClientVersion()).toEqual({ name: 'legacy-client', version: '1.0.0' }); + expect(server.getClientCapabilities()).toEqual({ sampling: {} }); + expect(server.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + + await close(); + }); +}); diff --git a/packages/server/test/server/invokeSeam.test.ts b/packages/server/test/server/invokeSeam.test.ts new file mode 100644 index 0000000000..98cd86377e --- /dev/null +++ b/packages/server/test/server/invokeSeam.test.ts @@ -0,0 +1,139 @@ +/** + * The internal per-request invoke seam: one classified message in, one HTTP + * response out — value-returning and independently testable, with no HTTP + * server and no changes to protocol dispatch. + * + * The tests mark factory instances as modern-era through the package-internal + * negotiated-version hook, standing in for the HTTP entry that will own that + * write in production. + */ +import type { JSONRPCNotification, JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + setNegotiatedProtocolVersion +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { invoke } from '../../src/server/invoke.js'; +import { McpServer } from '../../src/server/mcp.js'; +import { Server } from '../../src/server/server.js'; + +const MODERN_REVISION = '2026-07-28'; +const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION }; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'invoke-seam-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +const toolsCall = (name: string, args: Record): JSONRPCRequest => + ({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name, arguments: args, _meta: ENVELOPE } + }) as JSONRPCRequest; + +function modernMcpServer(): McpServer { + const mcpServer = new McpServer({ name: 'invoke-seam-test', version: '1.0.0' }); + mcpServer.registerTool('greet', { inputSchema: z.object({ who: z.string() }) }, async ({ who }) => ({ + content: [{ type: 'text', text: `hello ${who}` }] + })); + // Stand-in for the HTTP entry, which marks factory instances as modern-era + // at binding time through the same package-internal hook. + setNegotiatedProtocolVersion(mcpServer.server, MODERN_REVISION); + return mcpServer; +} + +describe('invoke', () => { + it('serves a classified request on a high-level server instance and returns the response value', async () => { + const response = await invoke(modernMcpServer(), toolsCall('greet', { who: 'world' }), { classification: MODERN }); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('hello world'); + }); + + it('serves a classified request on a low-level server instance', async () => { + const server = new Server({ name: 'low-level', version: '1.0.0' }, { capabilities: {} }); + server.setRequestHandler('app/sum', { params: z.looseObject({ a: z.number(), b: z.number() }) }, async params => ({ + sum: params.a + params.b + })); + setNegotiatedProtocolVersion(server, MODERN_REVISION); + const response = await invoke( + server, + { jsonrpc: '2.0', id: 7, method: 'app/sum', params: { a: 2, b: 3, _meta: ENVELOPE } } as JSONRPCRequest, + { classification: MODERN } + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { id: number; result: { sum: number } }; + expect(body.id).toBe(7); + expect(body.result.sum).toBe(5); + }); + + it('answers an era-removed method with method-not-found and HTTP 404', async () => { + const response = await invoke( + modernMcpServer(), + { jsonrpc: '2.0', id: 2, method: 'ping', params: { _meta: ENVELOPE } } as JSONRPCRequest, + { classification: MODERN } + ); + expect(response.status).toBe(404); + const body = (await response.json()) as { error: { code: number } }; + expect(body.error.code).toBe(-32_601); + }); + + it('acknowledges classified notifications with 202 and no body', async () => { + const response = await invoke( + modernMcpServer(), + { jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 99 } } as JSONRPCNotification, + { classification: MODERN } + ); + expect(response.status).toBe(202); + expect(await response.text()).toBe(''); + }); + + it('protects unmarked instances: modern-classified traffic gets the protocol-version error', async () => { + const mcpServer = new McpServer({ name: 'unmarked', version: '1.0.0' }); + mcpServer.registerTool('greet', { inputSchema: z.object({ who: z.string() }) }, async ({ who }) => ({ + content: [{ type: 'text', text: `hello ${who}` }] + })); + mcpServer.server.onerror = () => { + // the era mismatch is also surfaced out of band; irrelevant here + }; + const response = await invoke(mcpServer, toolsCall('greet', { who: 'world' }), { classification: MODERN }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: number; data: { supported: string[] } } }; + expect(body.error.code).toBe(-32_004); + expect(Array.isArray(body.error.data.supported)).toBe(true); + }); + + it('passes the original request and caller-supplied auth info through to handler context', async () => { + const mcpServer = new McpServer({ name: 'ctx-check', version: '1.0.0' }); + let seenAuthClientId: string | undefined; + let seenAuthorizationHeader: string | null | undefined; + mcpServer.registerTool('whoami', { inputSchema: z.object({}) }, async (_args, ctx) => { + seenAuthClientId = ctx.http?.authInfo?.clientId; + seenAuthorizationHeader = ctx.http?.req?.headers.get('authorization'); + return { content: [{ type: 'text', text: 'ok' }] }; + }); + setNegotiatedProtocolVersion(mcpServer.server, MODERN_REVISION); + + const request = new Request('http://localhost/mcp', { + method: 'POST', + headers: { authorization: 'Bearer raw-header-token' } + }); + const response = await invoke(mcpServer, toolsCall('whoami', {}), { + classification: MODERN, + request, + authInfo: { token: 'verified-token', clientId: 'client-42', scopes: ['mcp'] } + }); + expect(response.status).toBe(200); + // Caller-supplied auth info arrives as-is; the raw header stays a raw + // header and is never promoted to auth info by the seam. + expect(seenAuthClientId).toBe('client-42'); + expect(seenAuthorizationHeader).toBe('Bearer raw-header-token'); + }); +}); diff --git a/packages/server/test/server/legacyStatelessFallback.test.ts b/packages/server/test/server/legacyStatelessFallback.test.ts new file mode 100644 index 0000000000..8958a3516b --- /dev/null +++ b/packages/server/test/server/legacyStatelessFallback.test.ts @@ -0,0 +1,184 @@ +/** + * legacyStatelessFallback — the canonical `legacy` slot value, tested + * independently of createMcpHandler: per-request stateless serving via the + * frozen idiom (fresh instance + sessionIdGenerator: undefined + handleRequest). + */ +import { describe, expect, it, vi } from 'vitest'; +import * as z from 'zod/v4'; + +import type { McpRequestContext } from '../../src/server/createMcpHandler.js'; +import { legacyStatelessFallback } from '../../src/server/createMcpHandler.js'; +import { McpServer } from '../../src/server/mcp.js'; + +interface JSONRPCErrorBody { + jsonrpc: string; + id: unknown; + error: { code: number; message: string }; +} + +function postRequest(body: unknown): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify(body) + }); +} + +describe('legacyStatelessFallback', () => { + it('serves each POST on a fresh instance from the factory (stateless idiom)', async () => { + const contexts: McpRequestContext[] = []; + const products: McpServer[] = []; + const handler = legacyStatelessFallback(ctx => { + contexts.push(ctx); + const mcpServer = new McpServer({ name: 'fallback-test', version: '1.0.0' }); + mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] + })); + products.push(mcpServer); + return mcpServer; + }); + + const first = await handler( + postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'echo', arguments: { text: 'one' } } }) + ); + expect(first.status).toBe(200); + expect(await first.text()).toContain('one'); + + const second = await handler( + postRequest({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'echo', arguments: { text: 'two' } } }) + ); + expect(second.status).toBe(200); + expect(await second.text()).toContain('two'); + + expect(products).toHaveLength(2); + expect(products[0]).not.toBe(products[1]); + expect(contexts.every(ctx => ctx.era === 'legacy')).toBe(true); + }); + + it('passes caller-provided authInfo and parsedBody through to the legacy transport', async () => { + let seenClientId: string | undefined; + const handler = legacyStatelessFallback(() => { + const mcpServer = new McpServer({ name: 'fallback-auth', version: '1.0.0' }); + mcpServer.registerTool('whoami', { inputSchema: z.object({}) }, async (_args, ctx) => { + seenClientId = ctx.http?.authInfo?.clientId; + return { content: [{ type: 'text', text: 'ok' }] }; + }); + return mcpServer; + }); + + const body = { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'whoami', arguments: {} } }; + const response = await handler(postRequest(body), { + authInfo: { token: 'verified', clientId: 'fallback-client', scopes: [] }, + parsedBody: body + }); + expect(response.status).toBe(200); + // Drain the exchange before asserting: the tool handler runs while the + // per-request stream is open. + expect(await response.text()).toContain('ok'); + expect(seenClientId).toBe('fallback-client'); + }); + + it('answers GET and DELETE with 405 / Method not allowed. like the canonical stateless example', async () => { + const handler = legacyStatelessFallback(() => new McpServer({ name: 'fallback-405', version: '1.0.0' })); + + for (const method of ['GET', 'DELETE']) { + const response = await handler(new Request('http://localhost/mcp', { method })); + expect(response.status).toBe(405); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_000); + expect(body.error.message).toBe('Method not allowed.'); + expect(body.id).toBeNull(); + } + }); + + it('tears the per-request pair down after a normally-completed SSE exchange (factory product close hooks fire)', async () => { + let productClosed = false; + const handler = legacyStatelessFallback(() => { + const mcpServer = new McpServer({ name: 'fallback-teardown', version: '1.0.0' }); + mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] + })); + mcpServer.server.onclose = () => { + productClosed = true; + }; + return mcpServer; + }); + + const response = await handler( + postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'echo', arguments: { text: 'all done' } } }) + ); + expect(response.status).toBe(200); + // Request-bearing POSTs are answered over SSE by the stateless idiom's + // default transport options — the dominant legacy exchange shape. + expect(response.headers.get('content-type')).toContain('text/event-stream'); + expect(productClosed).toBe(false); + + // Drain the stream to completion: only then is the exchange over. + expect(await response.text()).toContain('all done'); + await vi.waitFor(() => { + expect(productClosed).toBe(true); + }); + }); + + it('still tears the per-request pair down when the client aborts a streaming exchange', async () => { + let productClosed = false; + const handler = legacyStatelessFallback(ctx => { + const mcpServer = new McpServer({ name: 'fallback-abort', version: '1.0.0' }); + mcpServer.registerTool('park', { inputSchema: z.object({}) }, async (_args, toolCtx) => { + await new Promise(resolve => { + toolCtx.mcpReq.signal.addEventListener('abort', () => resolve(), { once: true }); + }); + return { content: [{ type: 'text', text: `parked on ${ctx.era}` }] }; + }); + mcpServer.server.onclose = () => { + productClosed = true; + }; + return mcpServer; + }); + + const controller = new AbortController(); + const request = 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: 1, method: 'tools/call', params: { name: 'park', arguments: {} } }), + signal: controller.signal + }); + + const response = await handler(request); + expect(response.status).toBe(200); + expect(productClosed).toBe(false); + + controller.abort(); + await vi.waitFor(() => { + expect(productClosed).toBe(true); + }); + }); + + it('answers factory failures with a 500 internal error body', async () => { + const handler = legacyStatelessFallback(() => { + throw new Error('factory exploded'); + }); + const response = await handler(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} })); + expect(response.status).toBe(500); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_603); + }); + + it('reports failures through the optional onerror callback while keeping the 500 response', async () => { + const onerror = vi.fn(); + const handler = legacyStatelessFallback(() => { + throw new Error('factory exploded'); + }, onerror); + + const response = await handler(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} })); + expect(response.status).toBe(500); + expect(((await response.json()) as JSONRPCErrorBody).error.code).toBe(-32_603); + expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: 'factory exploded' })); + }); +}); diff --git a/packages/server/test/server/originValidation.test.ts b/packages/server/test/server/originValidation.test.ts new file mode 100644 index 0000000000..e0a3d4ab43 --- /dev/null +++ b/packages/server/test/server/originValidation.test.ts @@ -0,0 +1,67 @@ +/** + * Framework-agnostic Origin validation helpers: allowlist matching, the + * absent-header pass, and the deny-on-failure behavior for malformed values. + */ +import { describe, expect, it } from 'vitest'; + +import { localhostAllowedOrigins, originValidationResponse, validateOriginHeader } from '../../src/server/middleware/originValidation.js'; + +describe('validateOriginHeader', () => { + it('passes when no Origin header is present (non-browser clients)', () => { + expect(validateOriginHeader(undefined, ['localhost']).ok).toBe(true); + expect(validateOriginHeader(null, ['localhost']).ok).toBe(true); + expect(validateOriginHeader('', ['localhost']).ok).toBe(true); + }); + + it('allows origins whose hostname is on the allowlist, port- and scheme-agnostic', () => { + expect(validateOriginHeader('http://localhost:3000', ['localhost']).ok).toBe(true); + expect(validateOriginHeader('https://localhost', ['localhost']).ok).toBe(true); + expect(validateOriginHeader('http://127.0.0.1:8080', localhostAllowedOrigins()).ok).toBe(true); + expect(validateOriginHeader('http://[::1]:8080', localhostAllowedOrigins()).ok).toBe(true); + }); + + it('rejects origins whose hostname is not on the allowlist', () => { + const result = validateOriginHeader('http://evil.example.com', localhostAllowedOrigins()); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorCode).toBe('invalid_origin'); + expect(result.message).toContain('evil.example.com'); + } + }); + + it('rejects lookalike subdomains of allowed hostnames', () => { + expect(validateOriginHeader('http://localhost.evil.example.com', localhostAllowedOrigins()).ok).toBe(false); + }); + + it('denies on failure: unparseable Origin values and the opaque null origin are rejected, never passed through', () => { + for (const malformed of ['null', 'not a url', 'evil.example.com', 'about:blank']) { + const result = validateOriginHeader(malformed, localhostAllowedOrigins()); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorCode).toBe('invalid_origin_header'); + } + } + }); +}); + +describe('originValidationResponse', () => { + it('returns undefined for allowed and absent origins', () => { + const allowed = new Request('http://localhost/mcp', { headers: { origin: 'http://localhost:3000' } }); + expect(originValidationResponse(allowed, localhostAllowedOrigins())).toBeUndefined(); + + const absent = new Request('http://localhost/mcp'); + expect(originValidationResponse(absent, localhostAllowedOrigins())).toBeUndefined(); + }); + + it('returns a 403 JSON-RPC error response for disallowed origins', async () => { + const request = new Request('http://localhost/mcp', { headers: { origin: 'http://evil.example.com' } }); + const response = originValidationResponse(request, localhostAllowedOrigins()); + expect(response).toBeDefined(); + expect(response!.status).toBe(403); + const body = (await response!.json()) as { jsonrpc: string; error: { code: number; message: string }; id: unknown }; + expect(body.jsonrpc).toBe('2.0'); + expect(body.error.code).toBe(-32_000); + expect(body.error.message).toContain('Invalid Origin'); + expect(body.id).toBeNull(); + }); +}); diff --git a/packages/server/test/server/perRequestStreaming.test.ts b/packages/server/test/server/perRequestStreaming.test.ts new file mode 100644 index 0000000000..ba56b6e543 --- /dev/null +++ b/packages/server/test/server/perRequestStreaming.test.ts @@ -0,0 +1,251 @@ +/** + * Per-request streaming behavior: the lazy JSON-to-SSE upgrade, sink + * discipline (write order, drain-before-finalize, post-close drops), the + * forced response modes the entry-level knob will plug into, comment-frame + * support, and disconnect-as-cancellation. + */ +import type { CallToolResult, JSONRPCRequest, MessageClassification, ServerContext } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + setNegotiatedProtocolVersion +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import type { PerRequestResponseMode } from '../../src/server/perRequestTransport.js'; +import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport.js'; +import { Server } from '../../src/server/server.js'; + +const MODERN_REVISION = '2026-07-28'; +const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION }; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'streaming-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +const toolsCall = (id = 1): JSONRPCRequest => + ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: 'echo', arguments: {}, _meta: ENVELOPE } + }) as JSONRPCRequest; + +const progressNotification = (progress: number) => ({ + method: 'notifications/progress' as const, + params: { progressToken: 'stream-test', progress } +}); + +interface StreamingSetup { + server: Server; + transport: PerRequestHTTPServerTransport; +} + +async function setup( + handler: (ctx: ServerContext) => Promise, + responseMode?: PerRequestResponseMode +): Promise { + const server = new Server({ name: 'streaming-test', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/call', async (_request, ctx) => handler(ctx)); + setNegotiatedProtocolVersion(server, MODERN_REVISION); + const transport = new PerRequestHTTPServerTransport({ + classification: MODERN, + ...(responseMode !== undefined && { responseMode }) + }); + await server.connect(transport); + return { server, transport }; +} + +/** SSE frames of a fully-drained response body, split on the blank-line separator. */ +async function sseFrames(response: Response): Promise { + const text = await response.text(); + return text + .split('\n\n') + .map(frame => frame.trim()) + .filter(frame => frame.length > 0); +} + +const dataOf = (frame: string): unknown => { + const dataLine = frame.split('\n').find(line => line.startsWith('data: ')); + return dataLine === undefined ? undefined : JSON.parse(dataLine.slice('data: '.length)); +}; + +describe('lazy upgrade matrix', () => { + it('answers a handler with no streamed output as a single JSON body', async () => { + const { transport } = await setup(async () => ({ content: [{ type: 'text', text: 'plain' }] })); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + expect(response.headers.get('x-accel-buffering')).toBeNull(); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('plain'); + }); + + it('upgrades to SSE on the first related notification', async () => { + const { transport } = await setup(async ctx => { + await ctx.mcpReq.notify(progressNotification(1)); + return { content: [{ type: 'text', text: 'streamed' }] }; + }); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + expect(response.headers.get('cache-control')).toBe('no-cache'); + expect(response.headers.get('x-accel-buffering')).toBe('no'); + + const frames = await sseFrames(response); + expect(frames).toHaveLength(2); + expect(dataOf(frames[0]!)).toMatchObject({ method: 'notifications/progress' }); + expect(dataOf(frames[1]!)).toMatchObject({ id: 1, result: { content: [{ type: 'text', text: 'streamed' }] } }); + }); + + it('drains every streamed message before the terminal result and then ends the stream', async () => { + const { transport } = await setup(async ctx => { + await ctx.mcpReq.notify(progressNotification(1)); + await ctx.mcpReq.notify(progressNotification(2)); + await ctx.mcpReq.notify(progressNotification(3)); + return { content: [{ type: 'text', text: 'done' }] }; + }); + const response = await transport.handleMessage(toolsCall()); + const frames = await sseFrames(response); + expect(frames).toHaveLength(4); + const progressValues = frames.slice(0, 3).map(frame => (dataOf(frame) as { params: { progress: number } }).params.progress); + expect(progressValues).toEqual([1, 2, 3]); + expect(dataOf(frames[3]!)).toMatchObject({ result: { content: [{ type: 'text', text: 'done' }] } }); + }); + + it('emits no resumability bytes: no event ids, no retry hints, no priming events', async () => { + const { transport } = await setup(async ctx => { + await ctx.mcpReq.notify(progressNotification(1)); + return { content: [] }; + }); + const response = await transport.handleMessage(toolsCall()); + const text = await response.text(); + expect(text).not.toMatch(/^id:/m); + expect(text).not.toMatch(/^retry:/m); + expect(response.headers.get('mcp-session-id')).toBeNull(); + }); + + it('drops writes after the exchange is closed', async () => { + // A streamed exchange whose stream has already been finalized: a late + // related write must be dropped by the closed-guard. If that guard + // were removed, the write would hit the closed stream controller and + // be reported through onerror. + const { transport } = await setup(async ctx => { + await ctx.mcpReq.notify(progressNotification(1)); + return { content: [] }; + }); + const response = await transport.handleMessage(toolsCall()); + await response.text(); + await transport.close(); + const errors: Error[] = []; + transport.onerror = error => errors.push(error); + await expect(transport.send(progressNotification(9) as never, { relatedRequestId: 1 })).resolves.toBeUndefined(); + expect(errors).toHaveLength(0); + }); +}); + +describe('forced response modes (the seam the entry-level knob plugs into)', () => { + it('sse mode opens the stream immediately, even with no streamed output', async () => { + const { transport } = await setup(async () => ({ content: [{ type: 'text', text: 'eager' }] }), 'sse'); + const response = await transport.handleMessage(toolsCall()); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + const frames = await sseFrames(response); + expect(frames).toHaveLength(1); + expect(dataOf(frames[0]!)).toMatchObject({ result: { content: [{ type: 'text', text: 'eager' }] } }); + }); + + it('sse mode still answers pre-dispatch rejections with their mapped HTTP status', async () => { + // The forced-sse stream opens only after the pre-dispatch gates pass: + // a request the validation ladder rejects (here: an unknown method + // with no handler) keeps the spec-mandated HTTP status instead of + // being framed onto a 200 stream. + const { transport } = await setup(async () => ({ content: [] }), 'sse'); + const unknownMethod = { + jsonrpc: '2.0', + id: 1, + method: 'definitely/unknown', + params: { _meta: ENVELOPE } + } as JSONRPCRequest; + const response = await transport.handleMessage(unknownMethod); + expect(response.status).toBe(404); + expect(response.headers.get('content-type')).toContain('application/json'); + const body = (await response.json()) as { error?: { code: number } }; + expect(body.error?.code).toBe(-32_601); + }); + + it('json mode never upgrades and drops mid-call notifications', async () => { + const { transport } = await setup(async ctx => { + await ctx.mcpReq.notify(progressNotification(1)); + await ctx.mcpReq.notify(progressNotification(2)); + return { content: [{ type: 'text', text: 'json-only' }] }; + }, 'json'); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('json-only'); + // The notifications were dropped, not buffered into the body. + expect(JSON.stringify(body)).not.toContain('notifications/progress'); + }); +}); + +describe('comment frames', () => { + it('writes comment frames into an open stream and drops them otherwise', async () => { + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + const { transport } = await setup(async () => { + await gate; + return { content: [] }; + }, 'sse'); + + const responsePromise = transport.handleMessage(toolsCall()); + // The stream is open (sse mode settles once the pre-dispatch gates + // pass); a comment frame written now must be delivered to the + // consumer. + transport.writeCommentFrame('keep-alive'); + release(); + const response = await responsePromise; + const text = await response.text(); + expect(text).toContain(': keep-alive'); + + // After the exchange completed (and the transport closed itself), + // comment frames are dropped silently — and never surface as stream + // write errors, which is what would happen without the closed-guard. + const errors: Error[] = []; + transport.onerror = error => errors.push(error); + transport.writeCommentFrame('late'); + expect(errors).toHaveLength(0); + }); +}); + +describe('disconnect is cancellation', () => { + it('cancelling the SSE stream aborts the in-flight handler', async () => { + let observedSignal: AbortSignal | undefined; + let abortObserved!: () => void; + const aborted = new Promise(resolve => { + abortObserved = resolve; + }); + const { transport } = await setup(async ctx => { + observedSignal = ctx.mcpReq.signal; + ctx.mcpReq.signal.addEventListener('abort', () => abortObserved(), { once: true }); + await ctx.mcpReq.notify(progressNotification(1)); + await aborted; + return { content: [] }; + }); + const response = await transport.handleMessage(toolsCall()); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + + const reader = response.body!.getReader(); + await reader.read(); + // The client goes away: cancelling the response stream tears the + // exchange down and aborts the handler's signal. + await reader.cancel(); + await aborted; + expect(observedSignal?.aborted).toBe(true); + }); +}); diff --git a/packages/server/test/server/perRequestTransport.test.ts b/packages/server/test/server/perRequestTransport.test.ts new file mode 100644 index 0000000000..f61fd3cd00 --- /dev/null +++ b/packages/server/test/server/perRequestTransport.test.ts @@ -0,0 +1,386 @@ +/** + * The per-request HTTP server transport: single-exchange contract, the + * classification handoff into protocol dispatch, HTTP status mapping for + * pre-handler rejections, auth-info pass-through, and the close/teardown + * chain. + */ +import type { CallToolResult, JSONRPCNotification, JSONRPCRequest, MessageClassification, ServerContext } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + ProtocolError, + SdkError, + SdkErrorCode, + setNegotiatedProtocolVersion +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport.js'; +import { Server } from '../../src/server/server.js'; + +const MODERN_REVISION = '2026-07-28'; +const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION }; +const LEGACY: MessageClassification = { era: 'legacy' }; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'per-request-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +// `meta: null` builds an envelope-less request; the default is the full envelope. +const toolsCall = (id = 1, meta: Record | null = ENVELOPE): JSONRPCRequest => + ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: 'echo', arguments: {}, ...(meta !== null && { _meta: meta }) } + }) as JSONRPCRequest; + +const envelopedRequest = (method: string, id = 1): JSONRPCRequest => + ({ jsonrpc: '2.0', id, method, params: { _meta: ENVELOPE } }) as JSONRPCRequest; + +interface ServerSetup { + server: Server; + lastCtx: () => ServerContext | undefined; +} + +function modernServer(options: { toolsCallHandler?: (ctx: ServerContext) => Promise } = {}): ServerSetup { + const server = new Server({ name: 'per-request-test', version: '1.0.0' }, { capabilities: { tools: {} } }); + let captured: ServerContext | undefined; + const defaultHandler = async (): Promise => ({ content: [{ type: 'text', text: 'served' }] }); + server.setRequestHandler('tools/call', async (_request, ctx) => { + captured = ctx; + return (options.toolsCallHandler ?? defaultHandler)(ctx); + }); + setNegotiatedProtocolVersion(server, MODERN_REVISION); + return { server, lastCtx: () => captured }; +} + +async function connectedTransport( + server: Server, + options?: ConstructorParameters[0] +): Promise { + const transport = new PerRequestHTTPServerTransport(options ?? { classification: MODERN }); + await server.connect(transport); + return transport; +} + +const errorOf = (body: unknown) => (body as { error?: { code: number; message: string; data?: unknown } }).error; + +describe('single-exchange contract', () => { + it('throws when a message is handled before a server is connected', async () => { + const transport = new PerRequestHTTPServerTransport({ classification: MODERN }); + await expect(transport.handleMessage(toolsCall())).rejects.toThrow(/not connected/); + }); + + it('serves exactly one exchange — a second handleMessage throws', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server); + const first = await transport.handleMessage(toolsCall()); + expect(first.status).toBe(200); + await expect(transport.handleMessage(toolsCall(2))).rejects.toThrow(/exactly one exchange/); + }); + + it('cannot be started twice', async () => { + const transport = new PerRequestHTTPServerTransport({ classification: MODERN }); + await transport.start(); + await expect(transport.start()).rejects.toThrow(/already started/); + }); + + it('answers notification POST bodies with 202 and no body', async () => { + const { server } = modernServer(); + let delivered: string | undefined; + server.fallbackNotificationHandler = async notification => { + delivered = notification.method; + }; + const transport = await connectedTransport(server); + const response = await transport.handleMessage({ jsonrpc: '2.0', method: 'demo/heartbeat' } as JSONRPCNotification); + expect(response.status).toBe(202); + expect(await response.text()).toBe(''); + await new Promise(resolve => setTimeout(resolve, 5)); + expect(delivered).toBe('demo/heartbeat'); + await transport.close(); + await server.close(); + }); +}); + +describe('classification handoff into dispatch', () => { + it('serves a modern-classified request on a modern-marked instance', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('served'); + }); + + it('answers legacy-classified traffic on a modern-marked instance with the protocol-version error and HTTP 400', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server, { classification: LEGACY }); + server.onerror = () => { + // The mismatch is also surfaced out of band; irrelevant here. + }; + const response = await transport.handleMessage(toolsCall(1, null)); + expect(response.status).toBe(400); + const error = errorOf(await response.json()); + expect(error?.code).toBe(-32_004); + expect(error?.data).toMatchObject({ requested: expect.any(String), supported: expect.any(Array) }); + }); + + it('answers modern-classified traffic on an unmarked (legacy) instance with the protocol-version error', async () => { + const server = new Server({ name: 'unmarked', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/call', async () => ({ content: [] })); + server.onerror = () => { + // The mismatch is also surfaced out of band; irrelevant here. + }; + const transport = await connectedTransport(server, { classification: MODERN }); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(400); + expect(errorOf(await response.json())?.code).toBe(-32_004); + }); +}); + +describe('HTTP status mapping', () => { + it('maps method-not-found for an era-removed method to HTTP 404', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server); + // `ping` exists on the 2025 era but has no entry on the 2026 registry. + const response = await transport.handleMessage(envelopedRequest('ping')); + expect(response.status).toBe(404); + expect(errorOf(await response.json())).toMatchObject({ code: -32_601, message: 'Method not found' }); + }); + + it('maps method-not-found for an unknown method with no handler to HTTP 404', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(envelopedRequest('definitely/unknown')); + expect(response.status).toBe(404); + expect(errorOf(await response.json())?.code).toBe(-32_601); + }); + + it('keeps handler-produced errors in-band on HTTP 200, whatever their code', async () => { + const { server } = modernServer({ + toolsCallHandler: async () => { + throw new ProtocolError(-32_002, 'resource missing'); + } + }); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(errorOf(await response.json())).toMatchObject({ code: -32_002, message: 'resource missing' }); + }); + + it('keeps a handler-thrown method-not-found error in-band on HTTP 200 (the status table is origin-keyed)', async () => { + // A handler relaying a downstream -32601 (a proxy/relay tool is the + // realistic case) is a handler-produced error: it must not be + // re-mapped to HTTP 404 just because the ladder table maps that code + // for ladder-originated rejections. + const { server } = modernServer({ + toolsCallHandler: async () => { + throw new ProtocolError(-32_601, 'Method not found'); + } + }); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(errorOf(await response.json())).toMatchObject({ code: -32_601, message: 'Method not found' }); + }); + + it('keeps a handler-thrown unsupported-protocol-version error in-band on HTTP 200', async () => { + const { server } = modernServer({ + toolsCallHandler: async () => { + throw new ProtocolError(-32_004, 'Unsupported protocol version: 2099-01-01'); + } + }); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(errorOf(await response.json())?.code).toBe(-32_004); + }); + + it('keeps handler-produced invalid-params errors in-band on HTTP 200 (never status-mapped)', async () => { + const { server } = modernServer({ + toolsCallHandler: async () => { + throw new ProtocolError(-32_602, 'bad arguments'); + } + }); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(errorOf(await response.json())?.code).toBe(-32_602); + }); + + it('keeps the dispatch-level envelope check in-band: only the edge classifier maps invalid params to 400', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server); + // Modern-classified request without the _meta envelope: the dispatch + // layer rejects it with invalid params; the transport does not turn + // that into an HTTP-level failure. + const response = await transport.handleMessage(toolsCall(1, null)); + expect(response.status).toBe(200); + expect(errorOf(await response.json())?.code).toBe(-32_602); + }); +}); + +describe('auth info is strictly pass-through', () => { + it('never derives authInfo from the inbound request headers', async () => { + const { server, lastCtx } = modernServer(); + const transport = await connectedTransport(server); + const request = new Request('http://localhost/mcp', { + method: 'POST', + headers: { authorization: 'Bearer super-secret-token', 'content-type': 'application/json' } + }); + const response = await transport.handleMessage(toolsCall(), { request }); + expect(response.status).toBe(200); + const ctx = lastCtx(); + expect(ctx?.http?.req).toBe(request); + // The Authorization header is visible on the raw request, but it is + // never promoted to validated auth info by the transport. + expect(ctx?.http?.req?.headers.get('authorization')).toBe('Bearer super-secret-token'); + expect(ctx?.http?.authInfo).toBeUndefined(); + }); + + it('surfaces caller-provided authInfo unchanged', async () => { + const { server, lastCtx } = modernServer(); + const transport = await connectedTransport(server); + const authInfo = { token: 'validated-token', clientId: 'client-1', scopes: ['mcp'] }; + const response = await transport.handleMessage(toolsCall(), { authInfo }); + expect(response.status).toBe(200); + expect(lastCtx()?.http?.authInfo).toEqual(authInfo); + }); +}); + +describe('teardown and the close chain', () => { + it('close is idempotent and fires onclose exactly once', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server); + let closes = 0; + const previous = transport.onclose; + transport.onclose = () => { + closes += 1; + previous?.(); + }; + await transport.close(); + await transport.close(); + expect(closes).toBe(1); + }); + + it('server.close() and transport.close() do not re-enter each other', async () => { + const first = modernServer(); + const firstTransport = await connectedTransport(first.server); + await first.server.close(); + await firstTransport.close(); + + const second = modernServer(); + const secondTransport = await connectedTransport(second.server); + await secondTransport.close(); + await second.server.close(); + }); + + it('closing mid-request rejects the pending response and aborts the handler', async () => { + let observedSignal: AbortSignal | undefined; + const { server } = modernServer({ + toolsCallHandler: ctx => { + observedSignal = ctx.mcpReq.signal; + return new Promise(() => { + // never resolves; the exchange is torn down externally + }); + } + }); + const transport = await connectedTransport(server); + const pending = transport.handleMessage(toolsCall()); + const expectation = expect(pending).rejects.toSatisfy( + (error: unknown) => error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed + ); + await new Promise(resolve => setTimeout(resolve, 5)); + await transport.close(); + await expectation; + expect(observedSignal?.aborted).toBe(true); + }); + + it('an aborted request signal cancels the exchange', async () => { + let observedSignal: AbortSignal | undefined; + const { server } = modernServer({ + toolsCallHandler: ctx => { + observedSignal = ctx.mcpReq.signal; + return new Promise(() => { + // parked until the client goes away + }); + } + }); + const transport = await connectedTransport(server); + const abortController = new AbortController(); + const request = new Request('http://localhost/mcp', { method: 'POST', signal: abortController.signal }); + const pending = transport.handleMessage(toolsCall(), { request }); + const expectation = expect(pending).rejects.toSatisfy( + (error: unknown) => error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed + ); + await new Promise(resolve => setTimeout(resolve, 5)); + abortController.abort(); + await expectation; + expect(observedSignal?.aborted).toBe(true); + }); + + it('rejects with the typed connection-closed error when the request signal is already aborted', async () => { + const { server, lastCtx } = modernServer(); + const transport = await connectedTransport(server); + const abortController = new AbortController(); + abortController.abort(); + const request = new Request('http://localhost/mcp', { method: 'POST', signal: abortController.signal }); + await expect(transport.handleMessage(toolsCall(), { request })).rejects.toSatisfy( + (error: unknown) => error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed + ); + // The handler never ran; the exchange was torn down before dispatch. + expect(lastCtx()).toBeUndefined(); + }); + + it('drops writes after close without raising or reporting through onerror', async () => { + const { server } = modernServer(); + const transport = await connectedTransport(server); + await transport.close(); + // If the closed-guard were removed, this response (for a request the + // transport never saw) would be reported through onerror as an + // unknown-request-id write. + const errors: Error[] = []; + transport.onerror = error => errors.push(error); + await expect(transport.send({ jsonrpc: '2.0', id: 1, result: {} }, { relatedRequestId: 1 })).resolves.toBeUndefined(); + expect(errors).toHaveLength(0); + }); + + it('drops messages unrelated to the in-flight request', async () => { + const { server } = modernServer({ + toolsCallHandler: async () => ({ content: [{ type: 'text', text: 'done' }] }) + }); + const transport = await connectedTransport(server); + const pending = transport.handleMessage(toolsCall()); + // A session-wide notification with no related request has nowhere to + // go on a per-request exchange. + await transport.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' }); + const response = await pending; + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + }); +}); + +describe('custom-method requests', () => { + it('serves custom (extension) methods registered with explicit schemas', async () => { + const { server } = modernServer(); + server.setRequestHandler('app/echo', { params: z.looseObject({ value: z.string() }) }, async params => ({ + echoed: params.value + })); + const transport = await connectedTransport(server); + const response = await transport.handleMessage({ + jsonrpc: '2.0', + id: 4, + method: 'app/echo', + params: { value: 'hello', _meta: ENVELOPE } + } as JSONRPCRequest); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { echoed: string } }; + expect(body.result.echoed).toBe('hello'); + }); +}); diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index 0edcfd3af0..e47a0f065b 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -1,4 +1,4 @@ -import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'; +import type { CallToolResult, JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'; import { InitializeResultSchema, InMemoryTransport, @@ -129,5 +129,91 @@ describe('Server', () => { await server.close(); }); + + it('counter-offers only released versions when a draft revision is requested', async () => { + // ORDERING PIN — counter-offer leak guard. The initialize accept + // check and counter-offer are now ERA-AWARE: they consult only the + // legacy (pre-2026-07-28) subset of `supportedProtocolVersions`, + // because a 2026-07-28-or-later revision is never negotiated via + // the legacy initialize handshake (it is only selected through + // server/discover). This pin holds even after a future + // LATEST/SUPPORTED constant bump adds a modern revision: the + // counter-offer can never name it. The dual-era list arms live in + // discover.test.ts ("era-aware counter-offer ordering"). + const DRAFT_REVISION = '2026-07-28'; + expect(SUPPORTED_PROTOCOL_VERSIONS).not.toContain(DRAFT_REVISION); + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + + const respondedVersion = await initializeServer(server, DRAFT_REVISION); + + expect(respondedVersion).toBe(LATEST_PROTOCOL_VERSION); + expect(respondedVersion).not.toBe(DRAFT_REVISION); + expect(server.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + + await server.close(); + }); + }); + + describe('tools/call handler-result validation (required content)', () => { + // Server-side pin for the documented wire break (docs/migration.md, + // "CallToolResult.content … required at the wire boundary"): with the + // content.default([]) affordance removed, a handler result without + // `content` is rejected with -32602 `Invalid tools/call result` — + // never silently defaulted onto the wire — while an authored-content + // result passes through the wrapped handler untouched. + async function callToolOnServer(result: CallToolResult): Promise { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/call', () => result); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const received: JSONRPCMessage[] = []; + clientTransport.onmessage = message => void received.push(message); + await server.connect(serverTransport); + await clientTransport.start(); + + await clientTransport.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' } + } + }); + await clientTransport.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + await clientTransport.send({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'echo', arguments: {} } }); + await new Promise(resolve => setTimeout(resolve, 10)); + await server.close(); + + const response = received.find(message => (message as { id?: unknown }).id === 2); + if (!response) { + throw new Error('no tools/call response received'); + } + return response; + } + + it('rejects a structured-only handler result (no content) with -32602 Invalid tools/call result', async () => { + const response = await callToolOnServer({ structuredContent: { ok: true } } as unknown as CallToolResult); + + const error = (response as { error?: { code: number; message: string } }).error; + expect(error).toBeDefined(); + expect(error!.code).toBe(-32602); + expect(error!.message).toContain('Invalid tools/call result'); + }); + + it('passes an authored-content result through to the wire', async () => { + const response = await callToolOnServer({ + content: [{ type: 'text', text: 'hi' }], + structuredContent: { ok: true } + }); + + if (!isJSONRPCResultResponse(response)) { + throw new Error(`Expected a result response, got: ${JSON.stringify(response)}`); + } + const result = response.result as { content: unknown; structuredContent: unknown }; + expect(result.content).toEqual([{ type: 'text', text: 'hi' }]); + expect(result.structuredContent).toEqual({ ok: true }); + }); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ffd38d3dd..483ebc939c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1099,8 +1099,8 @@ importers: specifier: workspace:^ version: link:../../packages/client '@modelcontextprotocol/conformance': - specifier: 0.2.0-alpha.3 - version: 0.2.0-alpha.3(@cfworker/json-schema@4.1.1) + specifier: 0.2.0-alpha.4 + version: 0.2.0-alpha.4(@cfworker/json-schema@4.1.1) '@modelcontextprotocol/core': specifier: workspace:^ version: link:../../packages/core @@ -2111,8 +2111,8 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@modelcontextprotocol/conformance@0.2.0-alpha.3': - resolution: {integrity: sha512-YjdEKaKWswkJtRl0G3RmZCfljkAct3je834sqGHgasGeU2eUp7sb+6sJL0uNEaAY3XXWYumN/mjr6aPZbnbJMA==} + '@modelcontextprotocol/conformance@0.2.0-alpha.4': + resolution: {integrity: sha512-WAz/Q+Fmr2XFcytLkmbNAJvUi0vCciNLQbjkHnaUUSyPcqQZEVNfsLECZWhN8hRS8oGpGDl9OLR9yBtzyGIY2Q==} hasBin: true '@modelcontextprotocol/sdk@1.29.0': @@ -6001,7 +6001,7 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@modelcontextprotocol/conformance@0.2.0-alpha.3(@cfworker/json-schema@4.1.1)': + '@modelcontextprotocol/conformance@0.2.0-alpha.4(@cfworker/json-schema@4.1.1)': dependencies: '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) '@octokit/rest': 22.0.1 diff --git a/scripts/fetch-schema-twins.ts b/scripts/fetch-schema-twins.ts new file mode 100644 index 0000000000..c6464e38b6 --- /dev/null +++ b/scripts/fetch-schema-twins.ts @@ -0,0 +1,73 @@ +/** + * Vendors the generated `schema.json` twins from the spec repository into + * `packages/core/test/corpus/schema-twins/` as RAW UPSTREAM BYTES. + * + * The twins are TEST-ONLY conformance oracles (never bundled, never runtime): + * `packages/core/test/wire/schemaTwinConformance.test.ts` compiles them into + * generated validators and locks the hand-written wire layer to them. Their + * authority rests on provenance, so they are vendored verbatim — no + * formatting of any kind (the directory is .prettierignore'd) — and each file + * is locked to the manifest's sha256/byte values at test time. Any rewrite + * (prettier, an editor, a manual touch-up) turns CI red. + * + * Refresh ATOMICALLY with the matching spec.types anchor (see + * packages/core/src/types/README.md lifecycle rule 4). + * + * Usage: + * pnpm fetch:schema-twins [sha] # default: the manifest's current source commit + * + * Sources are fetched from GitHub at the given commit, mirroring + * scripts/fetch-spec-types.ts; the manifest's provenance values (source + * commit, sha256, byte size) are recomputed from the fetched bytes. + */ + +import { createHash } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const PROJECT_ROOT = join(dirname(__filename), '..'); + +const SPEC_REPO = 'modelcontextprotocol/modelcontextprotocol'; +const TWINS_DIR = join(PROJECT_ROOT, 'packages', 'core', 'test', 'corpus', 'schema-twins'); +const MANIFEST_PATH = join(TWINS_DIR, 'manifest.json'); + +interface TwinManifest { + comment: string; + source: { repository: string; commit: string }; + files: Record; +} + +async function fetchRawBytes(sha: string, upstreamPath: string): Promise { + const url = `https://raw.githubusercontent.com/${SPEC_REPO}/${sha}/${upstreamPath}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch ${upstreamPath}: ${response.status} ${response.statusText}`); + } + return Buffer.from(await response.arrayBuffer()); +} + +async function main(): Promise { + const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as TwinManifest; + const sha = process.argv[2] ?? manifest.source.commit; + + for (const [revision, entry] of Object.entries(manifest.files)) { + console.log(`[${revision}] Fetching ${entry.upstreamPath} at ${sha}`); + const bytes = await fetchRawBytes(sha, entry.upstreamPath); + // Verbatim: the twin IS the upstream artifact, byte for byte. + writeFileSync(join(TWINS_DIR, `${revision}.schema.json`), bytes); + entry.sha256 = createHash('sha256').update(bytes).digest('hex'); + entry.bytes = bytes.byteLength; + console.log(`[${revision}] ${entry.bytes} bytes, sha256 ${entry.sha256}`); + } + + manifest.source = { repository: SPEC_REPO, commit: sha }; + writeFileSync(MANIFEST_PATH, `${JSON.stringify(manifest, null, 4)}\n`, 'utf8'); + console.log(`Updated ${MANIFEST_PATH}`); +} + +main().catch((error: unknown) => { + console.error('Error:', error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/fetch-spec-examples.ts b/scripts/fetch-spec-examples.ts new file mode 100644 index 0000000000..d20b73eb62 --- /dev/null +++ b/scripts/fetch-spec-examples.ts @@ -0,0 +1,150 @@ +/** + * Vendors the draft-revision (2026-07-28) example corpus from the spec + * repository into `packages/core/test/corpus/fixtures/2026-07-28/`. + * + * The spec repository ships canonical example instances for the draft schema + * (`schema/draft/examples//*.json`). The corpus harness + * (`packages/core/test/corpus/specCorpus.test.ts`) parses every vendored + * example through the SDK's wire schemas, so accept-side drift between the + * SDK and the specification turns CI red. + * + * Files are vendored verbatim, plus a `manifest.json` recording provenance + * (source commit) and the directory/file inventory so corpus drift is loud. + * + * Usage: + * pnpm fetch:spec-examples --spec-dir + * pnpm fetch:spec-examples [sha] # fetch from GitHub (default: latest main) + * + * With `--spec-dir`, examples are read from a local checkout of + * modelcontextprotocol/modelcontextprotocol (provenance is the checkout's + * HEAD commit). Without it, sources are fetched from GitHub at the given + * commit, mirroring scripts/fetch-spec-types.ts. + */ + +import { execFileSync } from 'node:child_process'; +import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const PROJECT_ROOT = join(dirname(__filename), '..'); + +const SPEC_REPO = 'modelcontextprotocol/modelcontextprotocol'; +/** The upcoming protocol revision; its examples live in the spec repo's draft directory. */ +const DRAFT_REVISION = '2026-07-28'; +const EXAMPLES_PATH = 'schema/draft/examples'; +const OUTPUT_DIR = join(PROJECT_ROOT, 'packages', 'core', 'test', 'corpus', 'fixtures', DRAFT_REVISION); + +interface ExampleFile { + /** `/.json` relative to the examples root. */ + relPath: string; + content: string; +} + +async function fetchLatestSHA(): Promise { + const url = `https://api.github.com/repos/${SPEC_REPO}/commits?path=${EXAMPLES_PATH}&per_page=1`; + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to fetch commit info: ${response.status} ${response.statusText}`); + const commits = (await response.json()) as Array<{ sha: string }>; + if (!commits?.length) throw new Error('No commits found for the examples path'); + return commits[0].sha; +} + +async function listExamplesFromGitHub(sha: string): Promise { + const url = `https://api.github.com/repos/${SPEC_REPO}/git/trees/${sha}?recursive=1`; + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to fetch repo tree: ${response.status} ${response.statusText}`); + const tree = (await response.json()) as { truncated?: boolean; tree: Array<{ path: string; type: string }> }; + if (tree.truncated) throw new Error('GitHub tree listing truncated; cannot enumerate examples reliably'); + return tree.tree + .filter(entry => entry.type === 'blob' && entry.path.startsWith(`${EXAMPLES_PATH}/`) && entry.path.endsWith('.json')) + .map(entry => entry.path.slice(EXAMPLES_PATH.length + 1)); +} + +async function fetchExamplesFromGitHub(sha: string): Promise { + const relPaths = await listExamplesFromGitHub(sha); + const files: ExampleFile[] = []; + for (const relPath of relPaths) { + const url = `https://raw.githubusercontent.com/${SPEC_REPO}/${sha}/${EXAMPLES_PATH}/${relPath}`; + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to fetch ${relPath}: ${response.status} ${response.statusText}`); + files.push({ relPath, content: await response.text() }); + } + return files; +} + +function readExamplesFromDir(specDir: string): { files: ExampleFile[]; sha: string } { + const root = join(specDir, ...EXAMPLES_PATH.split('/')); + const files: ExampleFile[] = []; + for (const typeDir of readdirSync(root).sort()) { + const dirPath = join(root, typeDir); + if (!statSync(dirPath).isDirectory()) continue; + for (const file of readdirSync(dirPath).sort()) { + if (!file.endsWith('.json')) continue; + files.push({ relPath: `${typeDir}/${file}`, content: readFileSync(join(dirPath, file), 'utf8') }); + } + } + const sha = execFileSync('git', ['-C', specDir, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + return { files, sha }; +} + +function writeCorpus(files: ExampleFile[], sha: string): void { + if (files.length === 0) throw new Error('No example files found — refusing to write an empty corpus'); + + rmSync(OUTPUT_DIR, { recursive: true, force: true }); + mkdirSync(OUTPUT_DIR, { recursive: true }); + + const dirs: Record = {}; + for (const file of files.sort((a, b) => a.relPath.localeCompare(b.relPath))) { + // The path components come from outside this repo (a spec checkout or the + // GitHub trees API); reject anything that could escape the output directory. + const parts = file.relPath.split('/'); + if (parts.length !== 2 || parts.some(p => !p || p === '.' || p === '..' || p.includes('\\'))) { + throw new Error(`Unsafe or unexpected example path: ${file.relPath}`); + } + const [typeDir, fileName] = parts as [string, string]; + const destFile = resolve(OUTPUT_DIR, typeDir, fileName); + if (!destFile.startsWith(resolve(OUTPUT_DIR) + sep)) { + throw new Error(`Example path escapes the output directory: ${file.relPath}`); + } + mkdirSync(join(OUTPUT_DIR, typeDir), { recursive: true }); + // Validate now so a malformed upstream example fails the vendoring, not the harness. + JSON.parse(file.content); + writeFileSync(destFile, file.content); + (dirs[typeDir] ??= []).push(fileName); + } + + const manifest = { + revision: DRAFT_REVISION, + source: { repo: SPEC_REPO, path: EXAMPLES_PATH, commit: sha }, + regenerate: 'pnpm fetch:spec-examples --spec-dir # or [sha] to fetch from GitHub', + directoryCount: Object.keys(dirs).length, + fileCount: files.length, + directories: dirs + }; + writeFileSync(join(OUTPUT_DIR, 'manifest.json'), `${JSON.stringify(manifest, null, 4)}\n`); + + console.log(`Vendored ${files.length} example files across ${Object.keys(dirs).length} directories (source ${sha.slice(0, 8)})`); +} + +async function main(): Promise { + const args = process.argv.slice(2); + const specDirIndex = args.indexOf('--spec-dir'); + + if (specDirIndex !== -1) { + const specDir = args[specDirIndex + 1]; + if (!specDir) throw new Error('--spec-dir requires a path argument'); + const { files, sha } = readExamplesFromDir(specDir); + writeCorpus(files, sha); + return; + } + + const sha = args[0] ?? (await fetchLatestSHA()); + const files = await fetchExamplesFromGitHub(sha); + writeCorpus(files, sha); +} + +main().catch((error: unknown) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/fetch-spec-types.ts b/scripts/fetch-spec-types.ts index b0db8d486f..e1b1ee0eab 100644 --- a/scripts/fetch-spec-types.ts +++ b/scripts/fetch-spec-types.ts @@ -27,6 +27,23 @@ const UPSTREAM_SCHEMA_DIRS: Record = { '2026-07-28': 'draft' }; +/** + * Generation pin per released revision. Released revisions are frozen: without + * an explicit SHA argument, their types are regenerated from the pinned spec + * commit below — never from the latest upstream commit — so a released anchor + * can only change through a deliberate, reviewed repin. Moving a pin (or + * freezing a newly released revision) must land in the same commit that + * retargets `.github/workflows/update-spec-types.yml`. + * + * Draft-tracking revisions have no entry and float to the latest upstream + * commit via the nightly workflow's refresh PRs. + * + * See `packages/core/src/types/README.md` for the full lifecycle policy. + */ +const RELEASED_REVISION_PINS: Partial> = { + '2025-11-25': '0168c57fc74aba6e6dcf8f0b7191db3caaa5ad65' +}; + interface GitHubCommit { sha: string; } @@ -59,10 +76,14 @@ async function fetchSpecTypes(version: SpecVersion, sha: string): Promise { + const pinnedSHA = RELEASED_REVISION_PINS[version]; let sha: string; if (providedSHA) { console.log(`[${version}] Using provided SHA: ${providedSHA}`); sha = providedSHA; + } else if (pinnedSHA) { + console.log(`[${version}] Using pinned SHA for released revision: ${pinnedSHA}`); + sha = pinnedSHA; } else { console.log(`[${version}] Fetching latest commit SHA...`); sha = await fetchLatestSHA(version); diff --git a/test/conformance/expected-failures.2026-07-28.yaml b/test/conformance/expected-failures.2026-07-28.yaml new file mode 100644 index 0000000000..21792ec3a3 --- /dev/null +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -0,0 +1,106 @@ +# Expected failures for the carried-forward x 2026-07-28 legs +# (`test:conformance:client:2026` and `test:conformance:server:2026`, both +# `--suite all --spec-version 2026-07-28`). +# +# This baseline is separate from expected-failures.yaml because entries are +# keyed by scenario name only: a scenario that passes at its default version +# in the 2025 legs but fails when forced to 2026-07-28 (or vice versa) cannot +# be expressed in a shared file (the passing leg would flag the entry as +# stale). Like expected-failures.yaml, this single file covers both +# directions: the client 2026 leg reads the `client:` section and the server +# 2026 leg reads the `server:` section. Both burn down independently of the +# 2025 legs. +# +# Baseline established against the published @modelcontextprotocol/conformance +# release pinned in package.json. Newer conformance releases are adopted by +# deliberately bumping the pin and reconciling this file in the same change. +# +# Entries are grouped by what unblocks them. As each gap closes the +# corresponding scenarios start passing and MUST be removed from this list +# (the runner fails on stale entries), so the baseline burns down per +# milestone. + +client: + # --- SEP-837 (application_type during DCR) --- + # The sep-837-application-type-present check only fires on draft-version + # runs; the client omits application_type during Dynamic Client + # Registration, so every auth scenario that reaches DCR fails it on this + # leg (the same scenarios pass at their default version in the 2025 legs). + - auth/metadata-default + - auth/metadata-var1 + - auth/metadata-var2 + - auth/metadata-var3 + - auth/scope-from-www-authenticate + - auth/scope-from-scopes-supported + - auth/scope-omitted-when-undefined + - auth/token-endpoint-auth-basic + - auth/token-endpoint-auth-post + - auth/token-endpoint-auth-none + - auth/offline-access-not-supported + + # --- Auth scenarios cut short by the 2026 connection lifecycle --- + # The fixture's auth flow drives the 2025 stateful lifecycle; the + # 2026-mode mock rejects the MCP POST (-32001, missing + # MCP-Protocol-Version header) before the scope-escalation behaviour these + # scenarios measure, so no authorization requests are observed. Unblocks + # when the auth fixture flow speaks the 2026 per-request lifecycle. + - auth/scope-step-up + - auth/scope-retry-limit + + # --- Same gaps as the 2025 baseline (fail identically when forced to 2026-07-28) --- + # SEP-2322 (multi-round-trip requests): client does not echo requestState / + # handle IncompleteResult yet. + - sep-2322-client-request-state + # SEP-2243 (HTTP standardization): no fixture handler / client header support yet. + - http-custom-headers + - http-invalid-tool-headers + # SEP-2106 (JSON Schema $ref handling): no fixture handler for the scenario yet. + - json-schema-ref-no-deref + # SEP-2468 (authorization response iss parameter): not implemented in the client. + - auth/iss-supported + - auth/iss-not-advertised + - auth/iss-supported-missing + - auth/iss-wrong-issuer + - auth/iss-unexpected + - auth/iss-normalized + - auth/metadata-issuer-mismatch + # SEP-2352 (authorization server migration): client does not re-register + # when PRM authorization_servers changes. + - auth/authorization-server-migration + +server: + # --- Carried-forward scenarios (also run by the 2025 legs) --- + # Pre-existing fixture/baseline bug: the fixture tool's schema is a plain + # Zod object with none of the JSON Schema 2020-12 keywords the scenario + # checks; it fails identically at 2025 in `--suite all` (not a 2026-path + # regression). + - json-schema-2020-12 + # SEP-2164: server returns -32002 without the requested URI in error.data + # (WARNING-only; the expected-failures evaluator counts WARNINGs as + # failures). Same failure as in the 2025 baseline. + - sep-2164-resource-not-found + + # --- Draft scenarios (same failures and reasons as the `--suite draft` leg) --- + # SEP-2243 (HTTP header standardization): the reject cells the SDK does + # answer now use -32001 (HeaderMismatch), but missing-header enforcement + # (Mcp-Method, Mcp-Name) and the Mcp-Name cross-check are not implemented, + # so those reject cells are still accepted with 200. + - http-header-validation + # SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented + # in the SDK, so the fixture does not register the scenarios' diagnostic + # test_input_required_result_* tools. + - input-required-result-basic-elicitation + - input-required-result-basic-sampling + - input-required-result-basic-list-roots + - input-required-result-request-state + - input-required-result-multiple-input-requests + - input-required-result-multi-round + - input-required-result-non-tool-request + - input-required-result-result-type + - input-required-result-tampered-state + - input-required-result-capability-check + # SEP-2322 SHOULD-level behaviours (re-request missing inputResponses, + # ignore unrecognized inputResponses keys): WARNING-only, but the + # expected-failures evaluator counts WARNINGs as failures. + - input-required-result-missing-input-response + - input-required-result-ignore-extra-params diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index abfb3751d3..b22573d3f8 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -2,19 +2,15 @@ # CI exits 0 if only these fail, exits 1 on unexpected failures or stale entries. # # Baseline established against the published @modelcontextprotocol/conformance -# release pinned in package.json (0.2.0-alpha.3). Newer conformance releases +# release pinned in package.json (0.2.0-alpha.4). Newer conformance releases # are adopted by deliberately bumping the package.json pin and reconciling -# this file in the same change. 0.2.0-alpha.3 fixes the draft wire version -# (2026-07-28). Several auth scenarios in this baseline (auth/iss-*, -# auth/authorization-server-migration, auth/enterprise-managed-authorization) -# are still not shipped in the published release — the runner reports them -# unknown/failed; their entries below cover them either way. +# this file in the same change. # -# NOTE: the draft error-code assignments exercised by the SEP-2243 server -# scenarios (-32001 HeaderMismatch) and their neighbours (-32602, -32004) are -# still under discussion upstream (pending conformance #336). Those cells are -# treated as parameterized, not settled: the entries below record today's -# referee behavior and are re-derived when a #336-containing referee is pinned. +# NOTE: the SDK's modern-path rejection codes are aligned with what this +# referee asserts: header/body mismatches answer -32001 (HeaderMismatch) and a +# missing _meta envelope (or missing protocolVersion key) answers -32602. +# If a future published conformance release changes those assignments, the +# affected cells are re-derived when that release is pinned. # # Entries are grouped by SEP. As each SEP/milestone is implemented in the SDK the # corresponding scenarios start passing and MUST be removed from this list (the @@ -22,9 +18,6 @@ client: # --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) --- - # SEP-2575 (request metadata / _meta envelope): client does not populate the - # _meta envelope or the MCP-Protocol-Version header semantics yet. - - request-metadata # SEP-2322 (multi-round-trip requests): client does not echo requestState / # handle IncompleteResult yet. - sep-2322-client-request-state @@ -59,12 +52,9 @@ client: server: # --- Draft-spec scenarios (in `--suite draft`; the default `active` suite is green) --- - # SEP-2575 (stateless HTTP / _meta envelope): server has no stateless mode, - # _meta-derived capabilities, error-code mappings, or server/discover yet. - - server-stateless - # SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented; - # most scenarios currently fail early with "Session ID required" because the - # fixture only runs in stateful mode. + # SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented + # in the SDK, so the fixture does not register the scenarios' diagnostic + # test_input_required_result_* tools. - input-required-result-basic-elicitation - input-required-result-basic-sampling - input-required-result-basic-list-roots @@ -75,14 +65,11 @@ server: - input-required-result-result-type - input-required-result-tampered-state - input-required-result-capability-check - # SEP-2549 (caching): no ttlMs/cacheScope support; scenario also hits the - # stateful-mode "Session ID required" error. - - caching - # SEP-2243 (HTTP header standardization): -32001 HeaderMismatch handling and - # case-insensitive/whitespace-trimmed header validation not implemented. - # (Error-code cells parameterized pending conformance #336 — see header note.) + # SEP-2243 (HTTP header standardization): the reject cells the SDK does + # answer now use -32001 (HeaderMismatch), but missing-header enforcement + # (Mcp-Method, Mcp-Name) and the Mcp-Name cross-check are not implemented, + # so those reject cells are still accepted with 200. - http-header-validation - - http-custom-header-server-validation # WARNING-only entries: these scenarios emit no FAILURE checks, only SHOULD-level # WARNINGs, but the expected-failures evaluator counts WARNINGs as failures. # SEP-2164: server returns -32002 without the requested URI in error.data. diff --git a/test/conformance/package.json b/test/conformance/package.json index 7a1154b8ed..96becacab1 100644 --- a/test/conformance/package.json +++ b/test/conformance/package.json @@ -30,15 +30,17 @@ "client": "tsx scripts/cli.ts client", "test:conformance:client": "conformance client --command 'node --import tsx ./src/everythingClient.ts' --suite core --expected-failures ./expected-failures.yaml", "test:conformance:client:all": "conformance client --command 'node --import tsx ./src/everythingClient.ts' --suite all --expected-failures ./expected-failures.yaml", + "test:conformance:client:2026": "conformance client --command 'node --import tsx ./src/everythingClient.ts' --suite all --spec-version 2026-07-28 --expected-failures ./expected-failures.2026-07-28.yaml", "test:conformance:client:run": "node --import tsx ./src/everythingClient.ts", "test:conformance:server": "scripts/run-server-conformance.sh --expected-failures ./expected-failures.yaml", "test:conformance:server:draft": "scripts/run-server-conformance.sh --suite draft --expected-failures ./expected-failures.yaml", "test:conformance:server:all": "scripts/run-server-conformance.sh --suite all --expected-failures ./expected-failures.yaml", + "test:conformance:server:2026": "scripts/run-server-conformance.sh --suite all --spec-version 2026-07-28 --expected-failures ./expected-failures.2026-07-28.yaml", "test:conformance:server:run": "node --import tsx ./src/everythingServer.ts", "test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all" }, "devDependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.3", + "@modelcontextprotocol/conformance": "0.2.0-alpha.4", "@modelcontextprotocol/client": "workspace:^", "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/core": "workspace:^", diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index 05103eb26d..e58f5558c3 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -14,9 +14,12 @@ import { Client, + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, ClientCredentialsProvider, CrossAppAccessProvider, PrivateKeyJwtProvider, + PROTOCOL_VERSION_META_KEY, requestJwtAuthorizationGrant, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; @@ -96,6 +99,38 @@ function registerScenarios(names: string[], handler: ScenarioHandler): void { } } +// ============================================================================ +// 2026-07-28 (modern era) helpers +// ============================================================================ + +/** + * Spec versions whose wire lifecycle is the 2026-07-28 per-request envelope + * (no `initialize` handshake). The conformance runner passes the resolved + * spec version of the current scenario run via the + * MCP_CONFORMANCE_PROTOCOL_VERSION environment variable; when it names a + * modern version, version-spanning scenarios (e.g. tools_call) must speak the + * modern lifecycle instead of the 2025 stateful one. + */ +const MODERN_SPEC_VERSIONS = new Set(['2026-07-28']); + +function isModernConformanceRun(): boolean { + const version = process.env.MCP_CONFORMANCE_PROTOCOL_VERSION; + return version !== undefined && MODERN_SPEC_VERSIONS.has(version); +} + +/** + * The per-request `_meta` envelope every 2026-era request carries on the wire. + * Automatic envelope emission is not implemented in the client yet (it is a + * client-side follow-up), so modern-era requests attach it explicitly. + */ +function modernEnvelope(clientInfo: { name: string; version: string }, capabilities: object, protocolVersion: string | undefined) { + return { + [PROTOCOL_VERSION_META_KEY]: protocolVersion ?? '2026-07-28', + [CLIENT_INFO_META_KEY]: clientInfo, + [CLIENT_CAPABILITIES_META_KEY]: capabilities + }; +} + // ============================================================================ // Basic scenarios (initialize, tools_call) // ============================================================================ @@ -117,6 +152,10 @@ async function runBasicClient(serverUrl: string): Promise { // tools_call scenario needs to actually call a tool async function runToolsCallClient(serverUrl: string): Promise { + if (isModernConformanceRun()) { + return runToolsCallModernClient(serverUrl); + } + const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} }); const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); @@ -141,8 +180,60 @@ async function runToolsCallClient(serverUrl: string): Promise { logger.debug('Connection closed successfully'); } +// tools_call under a 2026-07-28 run: negotiate the modern era via +// server/discover (versionNegotiation), then drive the same tool flow with +// the per-request _meta envelope attached to every request. +async function runToolsCallModernClient(serverUrl: string): Promise { + const clientInfo = { name: 'test-client', version: '1.0.0' }; + const client = new Client(clientInfo, { capabilities: {}, versionNegotiation: { mode: 'auto' } }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Negotiated protocol version:', client.getNegotiatedProtocolVersion()); + + const envelope = modernEnvelope(clientInfo, {}, client.getNegotiatedProtocolVersion()); + const tools = await client.request({ method: 'tools/list', params: { _meta: envelope } }); + logger.debug('Successfully listed tools'); + + // Call the add_numbers tool + const addTool = tools.tools.find(t => t.name === 'add_numbers'); + if (addTool) { + const result = await client.request({ + method: 'tools/call', + params: { name: 'add_numbers', arguments: { a: 5, b: 3 }, _meta: envelope } + }); + logger.debug('Tool call result:', JSON.stringify(result, null, 2)); + } + + await client.close(); + logger.debug('Connection closed successfully'); +} + +// request-metadata scenario (SEP-2575): every request must carry the +// MCP-Protocol-Version header and the per-request _meta envelope, and the +// client must retry with a supported version when its first choice is +// rejected with -32004. The version-negotiation probe (server/discover plus +// the corrective continuation) is exactly that mechanism. +async function runRequestMetadataClient(serverUrl: string): Promise { + const clientInfo = { name: 'test-client', version: '1.0.0' }; + const client = new Client(clientInfo, { + capabilities: { roots: { listChanged: true }, sampling: {}, elicitation: {} }, + versionNegotiation: { mode: 'auto' } + }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Negotiated protocol version:', client.getNegotiatedProtocolVersion()); + + await client.close(); + logger.debug('Connection closed successfully'); +} + registerScenario('initialize', runBasicClient); registerScenario('tools_call', runToolsCallClient); +registerScenario('request-metadata', runRequestMetadataClient); // ============================================================================ // Auth scenarios - well-behaved client diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index 387054f0b1..16c7d49be1 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -12,7 +12,7 @@ import { randomUUID } from 'node:crypto'; import { localhostHostValidation } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { CallToolResult, EventId, EventStore, GetPromptResult, ReadResourceResult, StreamId } from '@modelcontextprotocol/server'; -import { isInitializeRequest, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import { classifyInboundRequest, createMcpHandler, isInitializeRequest, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; import cors from 'cors'; import type { Request, Response } from 'express'; import express from 'express'; @@ -872,6 +872,23 @@ function createMcpServer() { return mcpServer; } +// ===== 2026-07-28 (MODERN ERA) SERVING ===== + +// Modern-era traffic — requests claiming the per-request `_meta` envelope +// mechanism (SEP-2575), including `server/discover` and malformed variants of +// the claim — is served through `createMcpHandler`, backed by the same +// `createMcpServer()` fixture definition the 2025 sessions use. Legacy traffic +// never reaches this handler (see the routing in the POST handler below), so +// the 2025 stateful session path is unchanged. +const modernHandler = createMcpHandler(() => createMcpServer(), { + onerror: error => console.error('Modern-era MCP handler error:', error) +}); + +/** Normalize a possibly-repeated HTTP header to its first value. */ +function headerValue(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + // ===== EXPRESS APP ===== const app = express(); @@ -894,6 +911,23 @@ app.post('/mcp', async (req: Request, res: Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; try { + // 2026-07-28 (modern era) traffic: anything claiming the per-request + // envelope mechanism — including malformed claims, which must get the + // modern validation-ladder errors rather than the 2025 session errors — + // is served by the createMcpHandler entry. Legacy-classified requests + // (initialize, no-claim traffic, batches, posted responses) fall + // through to the stateful 2025 session path below, untouched. + const inbound = classifyInboundRequest({ + httpMethod: req.method, + protocolVersionHeader: headerValue(req.headers['mcp-protocol-version']), + mcpMethodHeader: headerValue(req.headers['mcp-method']), + body: req.body + }); + if (inbound.kind !== 'legacy') { + await modernHandler.node(req, res, req.body); + return; + } + let transport: NodeStreamableHTTPServerTransport; if (sessionId && transports[sessionId]) { diff --git a/test/e2e/CLAUDE.md b/test/e2e/CLAUDE.md index 7ecb2e06e4..c72d8f2a6e 100644 --- a/test/e2e/CLAUDE.md +++ b/test/e2e/CLAUDE.md @@ -58,6 +58,26 @@ note: 'stateless hosting has no server→client back-channel' `addedInSpecVersion` / `removedInSpecVersion` bound the spec versions a requirement applies to. A behavior changed by a spec release gets a sibling entry: the new entry lists every retired id it replaces in `supersedes` (an array, requires `addedInSpecVersion`), and each retired entry points back via `supersededBy` (requires `removedInSpecVersion`). A coverage gate enforces that the links resolve and are exactly symmetric. +## The createMcpHandler entry arms (entryStateless / entryModern) + +Two transport arms host the dual-era HTTP entry (`createMcpHandler`) in process via an injected fetch, exactly like the other HTTP arms. They are era-fixed (`TRANSPORT_SPEC_VERSIONS`), so each registers cells on exactly one spec-version axis: + +- `entryStateless` — the entry with the `legacy: 'stateless'` slot; the scenario's plain client is served per request through the slot. Cells run on the 2025-11-25 axis only. +- `entryModern` — the entry modern-only strict (no legacy slot); the scenario's client is put into pinned 2026-07-28 negotiation by the arm and the per-request `_meta` envelope is attached to every outgoing request/notification by the arm (a harness stop-gap until the client + emits it itself). Cells run on the 2026-07-28 axis only. + +Both arms are part of the default transport list, so unrestricted requirements run through the entry automatically. When a requirement cannot run on an entry arm, annotate it with a machine-readable reason instead of bending the test: + +```ts +entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' /* optional note */ }]; +``` + +Omitting `arm` excludes both arms. The reasons (`EntryExclusionReason` in types.ts) are the acceptance checklist for re-admitting cells when the corresponding entry feature lands; a coverage gate rejects annotations that would never have an effect. Requirement families that the +per-request entry structurally cannot serve at all (server→client requests, sessions/resumability, standalone GET streams, subscriptions) are already expressed through their `transports` restrictions and need no annotation. + +Arm-specific helpers: `wire()`'s fourth argument also accepts `entry` (createMcpHandler hosting overrides — e.g. a `responseMode` or a bring-your-own `legacy` slot value), the returned `Wired.httpLog` records every HTTP exchange (request body, status, content-type, a readable +response clone) for raw wire assertions, factories may accept the optional per-request context (`EntryServerFactory`), and `modernEnvelopeMeta()` builds the envelope for bodies that POST raw 2026-era requests through `wired.fetch`. + ## Running From the repo root (the suite is the `@modelcontextprotocol/test-e2e` workspace package): diff --git a/test/e2e/coverage.test.ts b/test/e2e/coverage.test.ts index ed580b9a74..4397ae5420 100644 --- a/test/e2e/coverage.test.ts +++ b/test/e2e/coverage.test.ts @@ -14,6 +14,7 @@ import { fileURLToPath } from 'node:url'; import { expect, test } from 'vitest'; import { REQUIREMENTS } from './requirements.js'; +import { ALL_SPEC_VERSIONS, ALL_TRANSPORTS, ENTRY_TRANSPORTS, TRANSPORT_SPEC_VERSIONS } from './types.js'; const E2E_DIR = path.dirname(fileURLToPath(import.meta.url)); @@ -88,6 +89,34 @@ test('every transport-restricted requirement explains why in note', () => { expect(missing).toEqual([]); }); +test('every entryExclusions annotation targets an entry arm the requirement would otherwise run on', () => { + const bad: string[] = []; + for (const [id, r] of Object.entries(REQUIREMENTS)) { + for (const exclusion of r.entryExclusions ?? []) { + const arms = exclusion.arm === undefined ? ENTRY_TRANSPORTS : [exclusion.arm]; + for (const arm of arms) { + const transports = r.transports ?? ALL_TRANSPORTS; + if (!transports.includes(arm)) { + bad.push(`${id}: entryExclusions targets '${arm}', which the requirement's transports never include`); + continue; + } + const versions = ALL_SPEC_VERSIONS.filter( + v => + (r.addedInSpecVersion === undefined || v >= r.addedInSpecVersion) && + (r.removedInSpecVersion === undefined || v < r.removedInSpecVersion) && + (TRANSPORT_SPEC_VERSIONS[arm]?.includes(v) ?? true) + ); + if (versions.length === 0) { + bad.push( + `${id}: entryExclusions targets '${arm}', which registers no cells within the requirement's spec-version bounds` + ); + } + } + } + } + expect(bad).toEqual([]); +}); + test('supersedes/supersededBy links are symmetric and resolve', () => { const bad: string[] = []; for (const [id, req] of Object.entries(REQUIREMENTS)) { diff --git a/test/e2e/fixtures/dual-era-stdio-server.ts b/test/e2e/fixtures/dual-era-stdio-server.ts new file mode 100644 index 0000000000..b99b9763a9 --- /dev/null +++ b/test/e2e/fixtures/dual-era-stdio-server.ts @@ -0,0 +1,29 @@ +/** + * Runnable dual-era stdio MCP server fixture for the dual-era stdio e2e cells. + * + * `eraSupport: 'dual-era'` is the single declared act on an otherwise ordinary + * hand-constructed McpServer connected to the unchanged StdioServerTransport. + * Spawned as a real child process (via tsx) by + * test/e2e/scenarios/stdio-dual-era.test.ts; exits when its stdin reaches EOF. + */ + +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { z } from 'zod/v4'; + +const server = new McpServer( + { name: 'dual-era-stdio-e2e-fixture', version: '1.0.0' }, + { capabilities: { tools: {} }, eraSupport: 'dual-era' } +); + +server.registerTool( + 'echo', + { + description: 'Echoes the input text back as a text content block.', + inputSchema: z.object({ text: z.string() }) + }, + ({ text }) => ({ content: [{ type: 'text', text }] }) +); + +await server.connect(new StdioServerTransport()); +process.stderr.write('[dual-era-stdio-server] ready\n'); diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index 0fe566be8c..4a5218b5f5 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -15,30 +15,93 @@ import { PassThrough } from 'node:stream'; import type { Client } from '@modelcontextprotocol/client'; import { SSEClientTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import type { EventStore, JSONRPCMessage, McpServer, Server } from '@modelcontextprotocol/server'; -import { InMemoryTransport, ReadBuffer, serializeMessage, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { + CreateMcpHandlerOptions, + EventStore, + Implementation, + JSONRPCMessage, + McpRequestContext, + McpServer, + Server, + Transport as SdkTransport +} from '@modelcontextprotocol/server'; +import { + createMcpHandler, + InMemoryTransport, + ReadBuffer, + serializeMessage, + WebStandardStreamableHTTPServerTransport +} from '@modelcontextprotocol/server'; import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import type { Transport } from '../types.js'; +import type { SpecVersion, Transport } from '../types.js'; import { startLegacySseHost } from './sse-host.js'; import type { SnifferOptions } from './wire-sniffer.js'; import { sniffTransport } from './wire-sniffer.js'; export type ServerFactory = () => McpServer | Server; +/** + * A factory that optionally consumes the createMcpHandler per-request context. + * The context is only supplied on the entry arms (where the entry constructs a + * fresh instance per request); on every other arm the factory is called with no + * arguments, so declare the parameter optional. + */ +export type EntryServerFactory = (ctx?: McpRequestContext) => McpServer | Server; + +/** One HTTP exchange recorded by the entry arms (see {@linkcode Wired.httpLog}). */ +export interface RecordedHttpExchange { + /** HTTP request method (GET/POST/DELETE). */ + method: string; + /** The request body text, when one was sent as a string. */ + requestBody?: string; + /** HTTP response status. */ + status: number; + /** Response content-type header (empty string when absent). */ + contentType: string; + /** An unread clone of the HTTP response, for byte-level assertions (`await exchange.response.text()`). */ + response: Response; +} + export interface Wired extends AsyncDisposable { readonly fetch?: (url: URL | string, init?: RequestInit) => Promise; readonly url?: URL; + /** + * Every HTTP exchange the wired client performed, in order, including the + * connect-time negotiation. Recorded by the createMcpHandler entry arms + * only — scenarios on those arms use it to assert raw wire facts (request + * bodies, response status/content-type/bytes) that the typed client API + * does not expose. + */ + readonly httpLog?: readonly RecordedHttpExchange[]; } /** - * The fourth argument controls the wire-format sniffer (see wire-sniffer.ts): - * every message the client sends or receives is validated against the SDK's - * spec-anchored Zod schemas. Tests that intentionally use vendor-extension - * methods pass `{ allowCustomMethods: true }`; tests that deliberately put - * malformed MCP on the wire pass `{ strictValidation: false }`. + * The fourth argument's sniffer options control the wire-format sniffer (see + * wire-sniffer.ts): every message the client sends or receives is validated + * against the SDK's spec-anchored Zod schemas. Tests that intentionally use + * vendor-extension methods pass `{ allowCustomMethods: true }`; tests that + * deliberately put malformed MCP on the wire pass `{ strictValidation: false }`. + * `entry` overrides the hosting options of the createMcpHandler entry arms + * (ignored by every other transport). */ -export async function wire(transport: Transport, makeServer: ServerFactory, client: Client, sniff: SnifferOptions = {}): Promise { +export interface WireOptions extends SnifferOptions { + /** + * createMcpHandler hosting overrides for the entry arms. Defaults: + * `{ legacy: 'stateless' }` on entryStateless (the canonical slot value) and + * modern-only strict (no legacy slot) on entryModern. `onerror` and + * `responseMode` pass through unchanged. + */ + entry?: CreateMcpHandlerOptions; +} + +export async function wire( + transport: Transport, + makeServer: ServerFactory | EntryServerFactory, + client: Client, + sniff: WireOptions = {} +): Promise { switch (transport) { case 'inMemory': { const server = makeServer(); @@ -67,6 +130,48 @@ export async function wire(transport: Transport, makeServer: ServerFactory, clie [Symbol.asyncDispose]: () => Promise.all([client.close(), handle.close()]).then(() => {}) }; } + case 'entryStateless': + case 'entryModern': { + // The dual-era HTTP entry (`createMcpHandler`) hosted in process via an + // injected fetch, exactly like the other HTTP arms. The scenario factory + // backs the entry directly (the entry calls it once per request with its + // per-request context). `entryStateless` serves the scenario's plain + // client through the entry's `legacy: 'stateless'` slot; `entryModern` + // keeps the endpoint modern-only strict and connects the client on the + // 2026-07-28 revision (pin-mode negotiation + the per-request envelope + // stop-gap). Every HTTP exchange is recorded on `httpLog`. + const handler = createMcpHandler( + makeServer, + transport === 'entryStateless' ? { legacy: 'stateless', ...sniff.entry } : { ...sniff.entry } + ); + const url = new URL('http://in-process/mcp'); + const httpLog: RecordedHttpExchange[] = []; + const fetch = async (u: URL | string, init?: RequestInit) => { + const request = new Request(u, init); + const response = await handler.fetch(request); + httpLog.push({ + method: request.method.toUpperCase(), + ...(typeof init?.body === 'string' && { requestBody: init.body }), + status: response.status, + contentType: response.headers.get('content-type') ?? '', + response: response.clone() + }); + return response; + }; + let clientTx = new StreamableHTTPClientTransport(url, { fetch }); + if (transport === 'entryModern') { + pinModernNegotiation(client); + clientTx = attachModernEnvelope(clientTx); + } + await client.connect(sniffTransport(clientTx, 'client', sniff)); + if (transport === 'entryModern') assertModernNegotiation(client); + return { + fetch, + url, + httpLog, + [Symbol.asyncDispose]: () => Promise.all([client.close(), handler.close()]).then(() => {}) + }; + } case 'sse': { // The legacy SSE transport needs a real socket: the factory's server is hosted on the // shipped SSEServerTransport (@modelcontextprotocol/server-legacy/sse) behind a loopback @@ -212,6 +317,96 @@ export function hostStateless(makeServer: ServerFactory): { handleRequest: HttpH }; } +// ─────────────────────────────────────────────────────────────────────────────── +// createMcpHandler entry arms (entryStateless / entryModern) — client-side shims +// ─────────────────────────────────────────────────────────────────────────────── + +/** The protocol revision the entryModern arm negotiates and claims per request. */ +const MODERN_REVISION: SpecVersion = '2026-07-28'; + +/** + * The per-request `_meta` envelope of a 2026-07-28 request, for scenario bodies + * that put raw HTTP requests on the wire (via `wired.fetch`) rather than going + * through the wired client. Typed calls through the wired client never need + * this — the entryModern arm attaches the envelope itself (see + * {@linkcode attachModernEnvelope}). + */ +export function modernEnvelopeMeta(clientInfo?: Implementation): Record { + return { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: clientInfo ?? { name: 'e2e-entry-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; +} + +/** + * Put the (already constructed) scenario client into pinned 2026-07-28 + * negotiation. Version negotiation is a constructor-only option and the + * scenario corpus constructs era-agnostic clients, so the entryModern arm flips + * the option on the instance before `connect()` — a harness stop-gap, not a + * public API. Clients that already opted into a negotiation mode are left + * untouched (their cells deliberately exercise that mode). + */ +function pinModernNegotiation(client: Client): void { + const internals = client as unknown as { _versionNegotiation?: { mode?: unknown } }; + internals._versionNegotiation ??= { mode: { pin: MODERN_REVISION } }; +} + +/** + * Fail fast if an entryModern connection did not actually negotiate the + * 2026-07-28 revision. Every cell on the arm asserts modern-path behavior, so + * a broken negotiation pin (or a regression in the discover negotiation) would + * otherwise surface as hundreds of unrelated downstream assertion failures; + * this turns it into one attributable arm-level error right after connect. + */ +function assertModernNegotiation(client: Client): void { + const negotiated = client.getNegotiatedProtocolVersion(); + if (negotiated !== MODERN_REVISION) { + throw new Error( + `entryModern arm: expected the connection to negotiate protocol version ${MODERN_REVISION}, but it negotiated ${negotiated ?? 'no version'}` + ); + } +} + +/** + * The per-request `_meta` envelope stop-gap for the entryModern arm: the + * negotiating client only attaches the envelope to its `server/discover` probe + * today (automatic per-request emission is a client-side follow-up), so the + * harness re-attaches the same envelope to every later request and notification + * the scenario's typed calls put on the wire. The envelope is captured from the + * latest enveloped message the client sent (normally the probe), so it always + * matches the most recent claim the client actually made — a connection that + * renegotiated would not keep stamping a stale version; messages that already + * carry a protocol-version claim (the probe, or a scenario's explicitly + * enveloped request) pass through untouched. + * + * Applied beneath the wire sniffer and `tapWire`, so recorded traffic shows the + * messages exactly as the scenario sent them while the wire carries the + * envelope the entry requires. + */ +function attachModernEnvelope(transport: T): T { + let envelope: Record | undefined; + const origSend = transport.send.bind(transport); + transport.send = async (message, opts) => { + let outbound = message; + if ('method' in message) { + const params = (message.params ?? {}) as { _meta?: Record }; + const meta = params._meta; + if (meta?.[PROTOCOL_VERSION_META_KEY] !== undefined) { + envelope = { + [PROTOCOL_VERSION_META_KEY]: meta[PROTOCOL_VERSION_META_KEY], + [CLIENT_INFO_META_KEY]: meta[CLIENT_INFO_META_KEY], + [CLIENT_CAPABILITIES_META_KEY]: meta[CLIENT_CAPABILITIES_META_KEY] + }; + } else if (envelope !== undefined) { + outbound = { ...message, params: { ...params, _meta: { ...envelope, ...meta } } }; + } + } + return origSend(outbound, opts); + }; + return transport; +} + // ─────────────────────────────────────────────────────────────────────────────── // In-process stdio client — TEST-ONLY // diff --git a/test/e2e/helpers/verifies.ts b/test/e2e/helpers/verifies.ts index bfcdc47216..0f2d07bdc4 100644 --- a/test/e2e/helpers/verifies.ts +++ b/test/e2e/helpers/verifies.ts @@ -18,11 +18,23 @@ import { describe, test } from 'vitest'; import { REQUIREMENTS } from '../requirements.js'; -import type { TestArgs } from '../types.js'; -import { ALL_SPEC_VERSIONS, ALL_TRANSPORTS } from '../types.js'; +import type { Requirement, SpecVersion, TestArgs, Transport } from '../types.js'; +import { ALL_SPEC_VERSIONS, ALL_TRANSPORTS, ENTRY_TRANSPORTS, TRANSPORT_SPEC_VERSIONS } from '../types.js'; type TestBody = (args: TestArgs) => Promise; +/** Whether a requirement's `entryExclusions` keep the given entry arm out of its cells. */ +function excludedFromEntryArm(req: Requirement, transport: Transport): boolean { + if (!(ENTRY_TRANSPORTS as readonly Transport[]).includes(transport)) return false; + return (req.entryExclusions ?? []).some(x => x.arm === undefined || x.arm === transport); +} + +/** Whether a transport arm serves the given spec version (era-fixed arms serve exactly one). */ +function transportServesVersion(transport: Transport, version: SpecVersion): boolean { + const versions = TRANSPORT_SPEC_VERSIONS[transport]; + return versions === undefined || versions.includes(version); +} + export function verifies(id: string | readonly string[], fn: TestBody, opts?: { title?: string }): void { const ids = Array.isArray(id) ? id : [id]; for (const rid of ids) registerOne(rid, fn, opts); @@ -33,13 +45,13 @@ function registerOne(id: string, fn: TestBody, opts?: { title?: string }): void if (!req) throw new Error(`verifies('${id}'): unknown requirement id`); if (req.deferred) throw new Error(`verifies('${id}'): requirement is deferred — drop the deferral or the test`); - const transports = req.transports ?? ALL_TRANSPORTS; + const transports = (req.transports ?? ALL_TRANSPORTS).filter(t => !excludedFromEntryArm(req, t)); const versions = ALL_SPEC_VERSIONS.filter( v => (req.addedInSpecVersion === undefined || v >= req.addedInSpecVersion) && (req.removedInSpecVersion === undefined || v < req.removedInSpecVersion) ); - const cells = versions.flatMap(v => transports.map(t => [t, v] as const)); + const cells = versions.flatMap(v => transports.filter(t => transportServesVersion(t, v)).map(t => [t, v] as const)); describe.each(cells)(`${id} [%s %s]`, (transport, protocolVersion) => { const kf = req.knownFailures?.find( diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index ea471a21fc..71b2462633 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -26,6 +26,7 @@ export const REQUIREMENTS: Record = { behavior: 'The client rejects calls to methods (e.g. resources/list) for capabilities the server did not advertise.' }, 'lifecycle:initialize:basic': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization', behavior: 'Connecting sends initialize with the protocol version, client capabilities, and client info; the server responds with its own and the connection is established.' @@ -35,24 +36,29 @@ export const REQUIREMENTS: Record = { behavior: 'A server may include an instructions string in the initialize result; the client exposes it.' }, 'lifecycle:initialized-notification': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization', behavior: 'After successful initialization, the client sends exactly one initialized notification, before any non-ping request.' }, 'lifecycle:ping': { + entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping#behavior-requirements', behavior: 'ping in either direction returns an empty result.' }, 'lifecycle:version:downgrade': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation', behavior: 'When the server returns an older supported protocol version, the client downgrades to it and the connection succeeds at that version.' }, 'lifecycle:version:match': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation', behavior: 'When the server supports the requested protocol version it echoes that version in the initialize result, and the connection proceeds at that version.' }, 'lifecycle:version:reject-unsupported': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation', behavior: 'When server returns a protocolVersion the client does not support, connect rejects and the transport is closed.', knownFailures: [ @@ -87,11 +93,13 @@ export const REQUIREMENTS: Record = { note: 'Under stateless hosting each request is served by a new server instance, so state set up earlier in the session cannot be observed.' }, 'lifecycle:version:server-fallback-latest': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation', behavior: 'An initialize request carrying a protocol version the server does not support is answered with another version the server supports — the latest one — rather than an error.' }, 'lifecycle:pre-initialization-ordering': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization', behavior: 'Before initialization completes, the client sends no requests other than pings, and the server sends no requests other than pings and logging.' @@ -150,10 +158,24 @@ export const REQUIREMENTS: Record = { ] }, 'protocol:cancel:unknown-id-ignored': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'method-not-in-modern-registry', + note: 'The body proves liveness after the ignored cancellation with ping, which the 2026-07-28 registry deletes; the ignored-cancellation behavior itself is still modern.' + } + ], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation#error-handling', behavior: 'The receiver silently ignores a cancellation notification referencing an unknown or already-completed request id; no error response is sent and no exception is raised.' }, + 'typescript:client:raw-result-type-first': { + source: 'sdk', + behavior: + 'A raw input_required result body through the full client path surfaces the discriminated kind as a typed local error (UNSUPPORTED_RESULT_TYPE with data.resultType) — never an empty-content success, on any spec-version axis.', + transports: ['inMemory', 'streamableHttp'], + note: 'The client funnel inspects the raw resultType before schema validation, closing the masking hazard where the tools/call result schema would default content to [] and report a hollow success. Raw relay servers stand in for a 2026-era peer; the streamableHttp leg uses a hand handler (custom fetch), so the cells exercise both an in-process and an HTTP response path.' + }, 'typescript:protocol:error:connection-closed': { source: 'sdk', behavior: 'Closing the transport invokes onclose and rejects all in-flight requests with ErrorCode.ConnectionClosed.', @@ -173,6 +195,7 @@ export const REQUIREMENTS: Record = { behavior: 'A request with malformed params is answered with JSON-RPC error -32602 Invalid params.' }, 'protocol:error:method-not-found': { + entryExclusions: [{ arm: 'entryModern', reason: 'modern-error-surface' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#responses', behavior: 'A request whose method has no registered handler is answered with a METHOD_NOT_FOUND error.' }, @@ -230,6 +253,13 @@ export const REQUIREMENTS: Record = { behavior: 'When a request times out, the sender issues notifications/cancelled for that request before failing the local call.' }, 'mcpserver:onerror:reach-through': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'requires-session', + note: 'The body delivers stray responses to a connected instance; on the modern path the entry classifier rejects posted responses before any per-request instance exists.' + } + ], source: 'sdk', behavior: 'Setting mcpServer.server.onerror (or server.onerror on raw Server) receives both transport-level errors and protocol/handler errors (uncaught notification handler, failed-to-send-response, unknown-message-id). The reach-through via McpServer.server is the supported access path until McpServer exposes onerror directly.' @@ -247,6 +277,13 @@ export const REQUIREMENTS: Record = { "A user-defined request schema registered via server.setRequestHandler(CustomSchema, h) is dispatched when client.request({method:'x/custom', params}, CustomResultSchema) is called; the handler's return value is parsed by the result schema and resolved to the caller. Capability checks do not reject non-spec method names." }, 'protocol:custom-method:roundtrip': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'modern-error-surface', + note: 'The custom-method round trip itself serves fine; the body also asserts the -32601 surface for a never-registered method, which differs on the modern path.' + } + ], source: 'sdk', behavior: "server.setRequestHandler with a schema whose method literal is NOT in the MCP spec registers a handler; client.request({method:''}, ResultSchema) returns the handler's result, not -32601 MethodNotFound. Capability assertions on both sides pass through unknown methods." @@ -274,6 +311,7 @@ export const REQUIREMENTS: Record = { note: 'Under stateless hosting each request is served by a new server instance, so state set up earlier in the session cannot be observed.' }, 'protocol:request-handler:override-builtin': { + entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], source: 'sdk', behavior: 'server.setRequestHandler() for a spec method that has a built-in handler (initialize, ping, logging/setLevel) replaces that handler; the user-supplied result is what the client receives. No throw on re-registration.' @@ -351,6 +389,13 @@ export const REQUIREMENTS: Record = { behavior: 'tools/call for a name the server does not recognise returns a JSON-RPC error.' }, 'tools:capability:declared': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'legacy-only-vocabulary', + note: 'server/discover deliberately omits the listChanged capability flag this body asserts.' + } + ], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#capabilities', behavior: 'A server that exposes tools declares the tools capability (optionally with listChanged) in its InitializeResult.' }, @@ -382,6 +427,13 @@ export const REQUIREMENTS: Record = { behavior: 'tools/list returns the registered tools with name, description, and inputSchema.' }, 'tools:list:metadata': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'legacy-only-vocabulary', + note: 'The 2026-07-28 wire deletes tools[].execution (taskSupport), which this body asserts round-trips.' + } + ], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool', behavior: 'tools/list includes title, annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint), _meta, icons, and execution.taskSupport when set.' @@ -491,6 +543,13 @@ export const REQUIREMENTS: Record = { 'Resources, resource templates, and resource contents may carry annotations {audience, priority, lastModified}; these round-trip from server registration to the client list/read result.' }, 'resources:capability:declared': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'legacy-only-vocabulary', + note: 'server/discover deliberately omits the listChanged capability flag this body asserts.' + } + ], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/resources#capabilities', behavior: 'A server with resource handlers advertises the resources capability, including the subscribe sub-flag when a subscribe handler is registered.' @@ -534,6 +593,7 @@ export const REQUIREMENTS: Record = { behavior: 'resources/read for an unknown URI returns JSON-RPC error -32002 (resource not found).' }, 'resources:subscribe:capability-required': { + entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/resources#capabilities', behavior: 'resources/subscribe to a server that did not advertise the subscribe capability is rejected with an error.' }, @@ -598,6 +658,13 @@ export const REQUIREMENTS: Record = { // Prompts 'prompts:capability:declared': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'legacy-only-vocabulary', + note: 'server/discover deliberately omits the listChanged capability flag this body asserts.' + } + ], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/prompts#capabilities', behavior: 'A server with a list_prompts handler advertises the prompts capability in its initialize result.' }, @@ -709,6 +776,7 @@ export const REQUIREMENTS: Record = { behavior: 'The completion result carries values (at most 100), an optional total, and an optional hasMore flag.' }, 'completion:complete:not-supported': { + entryExclusions: [{ arm: 'entryModern', reason: 'modern-error-surface' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion#capabilities', behavior: 'A server with no completion handler does not advertise the completions capability and rejects completion/complete with METHOD_NOT_FOUND.' @@ -726,6 +794,13 @@ export const REQUIREMENTS: Record = { behavior: 'A server that emits log message notifications declares the logging capability in its initialize result.' }, 'logging:message:fields': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'method-not-in-modern-registry', + note: 'The body scaffolds the exchange with logging/setLevel, which the 2026-07-28 registry deletes; notifications/message itself is still modern vocabulary.' + } + ], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging#log-message-notifications', behavior: "A log message sent by a server handler is delivered to the client's logging callback with its severity level, logger name, and data." @@ -743,6 +818,7 @@ export const REQUIREMENTS: Record = { note: 'Under stateless hosting each request is served by a new server instance, so state set up earlier in the session cannot be observed.' }, 'logging:set-level:invalid-level': { + entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging#error-handling', behavior: 'logging/setLevel with an invalid level value returns JSON-RPC error -32602 (Invalid params).', knownFailures: [ @@ -1127,11 +1203,13 @@ export const REQUIREMENTS: Record = { behavior: "_meta returned in a handler's result is delivered intact to the requesting client." }, 'protocol:request-id:unique': { + entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#requests', behavior: 'Every request sent on a session carries a unique, non-null string or integer id; ids are never reused within the session.' }, 'protocol:notifications:no-response': { + entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#notifications', behavior: 'Notifications are never answered: every message the server delivers is either the response to a request the client sent or a notification carrying no id.' @@ -2067,11 +2145,11 @@ export const REQUIREMENTS: Record = { note: 'This is an HTTP-specific flow requiring session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, 'flow:tool-result:resource-link-follow': { - transports: STATEFUL_TRANSPORTS, + transports: [...STATEFUL_TRANSPORTS, 'entryStateless', 'entryModern'], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#resource-links', behavior: 'A resource_link returned by a tool call can be followed with resources/read on the linked URI to retrieve the referenced contents.', - note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these.' + note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these. The createMcpHandler entry arms are included: the body is plain client→server request/response (a tools/call, then a resources/read against the same statically-registered factory), so the per-request entry serves it on both eras.' }, 'flow:proxy:forward-tools-resources': { transports: ['inMemory', 'streamableHttp'], @@ -2186,7 +2264,87 @@ export const REQUIREMENTS: Record = { behavior: 'An app created by createMcpExpressApp() with the default localhost host applies DNS-rebinding protection: a request whose Host header is not an allowed local host is rejected with 403 before reaching the MCP transport.', transports: ['streamableHttp'], - note: 'This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. The allowed-host control asserts initialize semantics per spec version: a 2026-era request is answered with the latest legacy version, since 2026-era revisions are never negotiated via initialize.' + }, + + // v2 features: dual-era serving (createMcpHandler entry, eraSupport stdio, result stamping) + + 'typescript:hosting:entry:dual-era-one-factory': { + source: 'sdk', + behavior: + 'createMcpHandler serves one ctx-taking factory to both protocol eras on one endpoint: with the legacy "stateless" slot configured, a plain client is served per request via initialize, tools/list and tools/call on the 2025 era, and an auto-negotiating client reaches 2026-07-28 via server/discover (never initialize) and gets tools/call served with the per-request _meta envelope.', + transports: ['entryStateless', 'entryModern'], + note: 'Runs on the createMcpHandler entry arms (the same one-factory, legacy-stateless-slot handler shape on both): the entryStateless cell drives the 2025 leg through the slot and the entryModern cell drives the modern path, with the never-initialize/server-discover clauses asserted on the arm-recorded HTTP exchanges.' + }, + 'typescript:hosting:entry:pin-negotiation': { + source: 'sdk', + behavior: + 'A client pinned to the 2026-07-28 revision (versionNegotiation mode pin) connects to a strict createMcpHandler endpoint without ever sending initialize — its first request is server/discover — and an enveloped tools/call round-trips.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'Runs on the entryModern arm (modern-only strict is its default hosting); the body constructs the pinned client itself and asserts the never-initialize, discover-first and envelope clauses on the arm-recorded HTTP exchanges.' + }, + 'typescript:hosting:entry:strict-rejects-legacy': { + source: 'sdk', + behavior: + 'A createMcpHandler endpoint with no legacy slot configured (modern-only strict) rejects a 2025-shaped initialize with the unsupported-protocol-version error carrying the supported modern revisions in error.data.supported; nothing is silently served on the 2025 era.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'Runs on the entryModern arm (modern-only strict is its default hosting); the 2025-shaped initialize and the plain-client connect attempt are driven against the harness-hosted endpoint via wired.fetch/wired.url. The numeric error code is asserted by message and supported-list shape only, since it shares a code with the still-disputed header/body mismatch family.' + }, + 'typescript:hosting:entry:notification-202': { + source: 'sdk', + behavior: + 'A POST carrying only a notification is answered 202 Accepted with an empty body by a createMcpHandler endpoint on both legs: an envelope-less notification through the legacy stateless slot and an envelope-carrying notification on the modern path.', + transports: ['entryStateless', 'entryModern'], + note: 'Runs on the createMcpHandler entry arms; each cell POSTs the raw notification through wired.fetch so the HTTP contract (status code and empty body) is observed directly, and the arm selects which leg the notification rides. Delivery of the notification to the per-request server instance is pinned at unit level.' + }, + 'typescript:hosting:entry:modern-cacheable-stamping': { + source: 'sdk', + behavior: + 'Typed tools/list, resources/read and resources/list round trips negotiated on 2026-07-28 over a createMcpHandler endpoint succeed, and the wire results carry resultType "complete" plus the required ttlMs/cacheScope fields, with the configured-hint precedence observable on the wire: the per-resource cacheHint wins over the per-operation cacheHints entry (resources/read), a per-operation hint wins over the defaults (tools/list), and a result with no configured author is filled with the ttlMs 0 / cacheScope private defaults (resources/list).', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'Runs on the entryModern arm; the typed round trips go through the wired negotiating client and the wire-level stamping is asserted on the arm-recorded response bytes. The top precedence rung — a handler-returned ttlMs/cacheScope value winning over every configured hint — is pinned at unit level and not exercised here.' + }, + 'typescript:hosting:entry:legacy-cacheable-suppression': { + source: 'sdk', + behavior: + 'A factory with every cache-hint author configured (per-operation cacheHints and a per-resource cacheHint), served to a plain 2025 client through the legacy stateless slot of a createMcpHandler endpoint, answers tools/list and resources/read with no resultType, ttlMs, cacheScope or cacheHint vocabulary anywhere in the response bytes.', + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + note: 'The suppression invariant is a statement about 2025-era serving, so the requirement is bounded to the 2025-11-25 axis and runs on the entryStateless arm; the response bytes are asserted on the arm-recorded HTTP exchanges.' + }, + 'typescript:hosting:entry:byo-sessionful-legacy': { + source: 'sdk', + behavior: + 'A real sessionful legacy wiring (per-session WebStandardStreamableHTTPServerTransport instances keyed by Mcp-Session-Id) passed as the createMcpHandler legacy slot value serves the full 2025-era session lifecycle through the entry: initialize issues an Mcp-Session-Id, a follow-up POST is served on that session, GET opens the standalone SSE stream, and DELETE tears the session down (a request carrying the dead session id answers 404).', + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + note: "The lifecycle is a statement about 2025-era serving through the bring-your-own legacy slot, so the requirement is bounded to the 2025-11-25 axis and runs on the entryStateless arm with the slot overridden via wire()'s entry.legacy option. It pins the entry routing of body-less GET and DELETE to the bring-your-own legacy slot, observed at the slot as method/status/content-type; byte-level forwarding fidelity is not asserted." + }, + 'typescript:hosting:entry:modern-lazy-sse-upgrade': { + source: 'sdk', + behavior: + 'On the default response mode, a modern (2026-07-28) request exchange over a createMcpHandler endpoint is answered as a single JSON body when the handler emits nothing before its result, and upgrades to an SSE stream when the handler emits related notifications mid-call: the response content-type becomes text/event-stream and the frames carry the notifications in emission order with the terminal result as the last frame.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'Runs on the entryModern arm; the typed calls go through the wired negotiating client and the response shape (status, content-type, SSE frame order) is asserted on the arm-recorded HTTP exchanges.' + }, + 'typescript:hosting:entry:modern-response-mode': { + source: 'sdk', + behavior: + 'The createMcpHandler responseMode option shapes modern (2026-07-28) request exchanges end to end: "sse" answers over an SSE stream even when the handler emits nothing before its result, and "json" answers with a single JSON body whose only payload is the terminal result — mid-call notifications are dropped, not buffered.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: "Runs on the entryModern arm; the body wires one harness-hosted endpoint per responseMode value via wire()'s entry.responseMode option and asserts the response shape on the arm-recorded HTTP exchanges." + }, + 'typescript:transport:stdio:dual-era-serving': { + source: 'sdk', + behavior: + 'A hand-constructed stdio server declaring eraSupport "dual-era" (transport line unchanged) serves a plain 2025 client via initialize and an auto-negotiating client on 2026-07-28 via server/discover, over a real child-process pipe.', + transports: ['stdio'], + note: 'Dual-era stdio serving is exercised against a real spawned child process (fixtures/dual-era-stdio-server.ts), so the matrix transport arg is ignored and the requirement lists stdio only; the spec-version axis selects which client drives the cell.' }, 'custom-methods:server-handler:roundtrip': { source: 'sdk', @@ -2209,10 +2367,11 @@ export const REQUIREMENTS: Record = { source: 'sdk', behavior: 'A notification handler registered for a non-spec method with a params schema receives schema-validated custom notifications sent by the remote side.', - transports: STATEFUL_TRANSPORTS, - note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these.' + transports: [...STATEFUL_TRANSPORTS, 'entryStateless', 'entryModern'], + note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these. The createMcpHandler entry arms are included: the server→client heartbeats are emitted during the tools/call exchange (ctx.mcpReq.notify) and observed after it completes, and the client→server heartbeat is a plain notification handled by the per-request instance, so the entry arms serve the body on both eras.' }, 'typescript:method-string-handlers:result-type-inference': { + entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], source: 'sdk', behavior: 'client.request() called with a spec method string and no result schema resolves with the result already parsed and validated for that method (ResultTypeMap inference), e.g. tools/list yields a usable tools array without passing a schema.' @@ -2316,11 +2475,13 @@ export const REQUIREMENTS: Record = { note: "This exercises the HTTP client transport's reconnection path; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs." }, 'lifecycle:version:custom-supported-versions': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'sdk', behavior: 'supportedProtocolVersions passed in Client/Server options overrides the negotiation list: a client requesting a version the server supports gets that version back, and both sides report the negotiated version after connect.' }, 'lifecycle:version:no-overlap-rejects': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'sdk', behavior: "When the server's negotiated protocol version is not in the client's supportedProtocolVersions list, client.connect() rejects and the connection is not established." @@ -2375,16 +2536,23 @@ export const REQUIREMENTS: Record = { note: 'This exercises the Streamable HTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, 'transport:standalone:raw-relay': { + entryExclusions: [ + { + reason: 'drives-transport-directly', + note: 'The body builds and hosts its own raw transports per matrix arm; an entry cell would re-run the streamable HTTP relay without exercising the entry.' + } + ], source: 'sdk', behavior: - 'Client and server transports can be driven directly (start/send/onmessage/onclose/onerror) without wrapping them in a Client or Server, supporting message-relay proxies.' + 'Client and server transports can be driven directly (start/send/onmessage/onclose/onerror) without wrapping them in a Client or Server, supporting message-relay proxies.', + note: 'Against real SDK servers the relayed initialize negotiates per initialize semantics: a 2026-era request is answered with the latest legacy version, since 2026-era revisions are never negotiated via initialize.' }, 'transport:custom:client-connect': { source: 'sdk', behavior: 'Client.connect accepts any consumer-implemented object satisfying the Transport interface and completes the handshake over it.', transports: ['inMemory'], - note: 'The test supplies its own custom Transport implementation, so the matrix transport arg is ignored; it runs as a single inMemory-labelled cell to avoid duplicate runs.' + note: 'The test supplies its own custom Transport implementation, so the matrix transport arg is ignored; it runs as a single inMemory-labelled cell to avoid duplicate runs. On 2026-era cells the handshake is the server/discover negotiation (opted into via versionNegotiation); on 2025-era cells it is the plain initialize exchange.' }, 'protocol:transport-callbacks:wrappable-after-connect': { source: 'sdk', diff --git a/test/e2e/scenarios/hosting-entry-session.test.ts b/test/e2e/scenarios/hosting-entry-session.test.ts new file mode 100644 index 0000000000..40d042104b --- /dev/null +++ b/test/e2e/scenarios/hosting-entry-session.test.ts @@ -0,0 +1,154 @@ +/** + * Sessionful 2025-era serving through the dual-era HTTP entry's + * bring-your-own legacy slot, exercised on the wire() entryStateless arm with + * the slot overridden via `wire()`'s `entry.legacy` option. + * + * The legacy slot value is a real sessionful wiring — one + * WebStandardStreamableHTTPServerTransport per session, kept in a map keyed by + * the Mcp-Session-Id the transport itself issues (the documented sessionful + * hosting pattern) — and a plain 2025 SDK client drives the full session + * lifecycle through the harness-hosted `createMcpHandler`: initialize issues a + * session id, a follow-up POST is served on that session, the body-less GET + * opens the standalone SSE stream, and DELETE tears the session down. Every + * exchange the slot serves is recorded as it leaves the wiring (method, status, + * content-type), so the entry's routing of GET/DELETE (no envelope, no body → + * legacy slot) to the bring-your-own handler is pinned directly; byte-level + * forwarding fidelity is not asserted here. + */ +import { randomUUID } from 'node:crypto'; + +import type { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { Client } from '@modelcontextprotocol/client'; +import type { LegacyHttpHandler, McpHandlerRequestOptions, McpRequestContext } from '@modelcontextprotocol/server'; +import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { expect, vi } from 'vitest'; +import { z } from 'zod/v4'; + +import { wire } from '../helpers/index.js'; +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const LEGACY = '2025-11-25'; + +/** The factory backing the modern path; this cell never drives it (the lifecycle under test is the legacy slot's). */ +function modernFactory(_ctx?: McpRequestContext): McpServer { + const server = new McpServer({ name: 'e2e-entry-session', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name} (modern)` }] + })); + return server; +} + +verifies('typescript:hosting:entry:byo-sessionful-legacy', async ({ transport }: TestArgs) => { + // The documented sessionful wiring, passed as the bring-your-own legacy + // slot value: a fresh transport per initialize, kept in a map keyed by the + // Mcp-Session-Id it issues; later requests are routed by that header. + const sessions = new Map(); + const closedSessions: string[] = []; + const sessionServers: McpServer[] = []; + + async function routeSessionRequest(request: Request, options?: McpHandlerRequestOptions): Promise { + const sessionId = request.headers.get('mcp-session-id'); + if (sessionId !== null) { + const existing = sessions.get(sessionId); + if (existing !== undefined) return existing.handleRequest(request, options); + // A request for a session this wiring no longer (or never) knew — + // the documented sessionful pattern answers 404. + return Response.json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }, { status: 404 }); + } + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: randomUUID, + onsessioninitialized: id => void sessions.set(id, transport), + onsessionclosed: id => { + closedSessions.push(id); + sessions.delete(id); + } + }); + const server = new McpServer({ name: 'byo-session-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name} (byo session)` }] + })); + sessionServers.push(server); + await server.connect(transport); + return transport.handleRequest(request, options); + } + + // Every exchange the entry forwards to the bring-your-own slot, recorded + // as it leaves the wiring: this is what proves the GET/DELETE routing. + const slotExchanges: Array<{ method: string; status: number; contentType: string }> = []; + const sessionfulLegacy: LegacyHttpHandler = async (request, options) => { + const response = await routeSessionRequest(request, options); + slotExchanges.push({ + method: request.method.toUpperCase(), + status: response.status, + contentType: response.headers.get('content-type') ?? '' + }); + return response; + }; + + const client = new Client({ name: 'plain-2025-client', version: '1.0.0' }); + try { + // The harness hosts the entry; the bring-your-own wiring replaces the + // arm's default 'stateless' slot value. + await using wired = await wire(transport, modernFactory, client, { entry: { legacy: sessionfulLegacy } }); + + // initialize → the bring-your-own transport issues an Mcp-Session-Id. + // (The stateless slot never issues one, so a defined session id alone + // proves the request reached the bring-your-own wiring.) + expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY); + const clientTransport = client.transport as StreamableHTTPClientTransport; + const sessionId = clientTransport.sessionId; + expect(sessionId).toBeDefined(); + expect(sessions.has(sessionId!)).toBe(true); + + // Follow-up POST on the session: served by the same per-session instance. + const result = await client.callTool({ name: 'greet', arguments: { name: 'session friend' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hello session friend (byo session)' }]); + expect(clientTransport.sessionId).toBe(sessionId); + + // GET route: the client opens its standalone SSE stream after + // initialization; the entry routes the body-less GET (no envelope) to + // the legacy slot, which answers it with the stream. + await vi.waitFor( + () => { + const get = slotExchanges.find(exchange => exchange.method === 'GET'); + if (get === undefined) throw new Error('the standalone GET stream has not reached the legacy slot yet'); + expect(get.status).toBe(200); + expect(get.contentType).toContain('text/event-stream'); + }, + { timeout: 5000, interval: 50 } + ); + + // DELETE route: terminating the session goes through the entry to the + // bring-your-own transport, which tears the session down. + await clientTransport.terminateSession(); + expect(closedSessions).toEqual([sessionId]); + const deleteExchange = slotExchanges.find(exchange => exchange.method === 'DELETE'); + expect(deleteExchange?.status).toBe(200); + + // Stop the client before probing the dead session so its standalone + // stream cannot reconnect underneath the assertion. + await client.close(); + + // The dead session is gone: a POST carrying its id is answered 404 by + // the bring-your-own wiring, not silently re-served by anything else. + const stale = await wired.fetch!(wired.url!, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId!, + 'mcp-protocol-version': LEGACY + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 99, method: 'tools/list', params: {} }) + }); + expect(stale.status).toBe(404); + await stale.text(); + // ...and that 404 was produced by the bring-your-own wiring (the probe + // reached the slot), not synthesized by the entry or anything in front of it. + expect(slotExchanges.some(exchange => exchange.method === 'POST' && exchange.status === 404)).toBe(true); + } finally { + await client.close().catch(() => {}); + for (const server of sessionServers) await server.close().catch(() => {}); + } +}); diff --git a/test/e2e/scenarios/hosting-entry-stamping.test.ts b/test/e2e/scenarios/hosting-entry-stamping.test.ts new file mode 100644 index 0000000000..6ef259ba16 --- /dev/null +++ b/test/e2e/scenarios/hosting-entry-stamping.test.ts @@ -0,0 +1,160 @@ +/** + * Result stamping and cache-field fill, end to end over the dual-era HTTP + * entry (`createMcpHandler`), with the era boundary asserted on the wire: + * + * - the entryModern cell (2026-07-28 axis): typed tools/list, resources/read + * and resources/list round trips through the negotiating client succeed, and + * the recorded wire results carry `resultType: 'complete'` plus the required + * `ttlMs`/`cacheScope` fields, with three rungs of the documented precedence + * observable on the wire: the per-resource hint wins over the per-operation + * hint (resources/read), a per-operation hint wins over the defaults + * (tools/list), and a result with no configured author is filled with the + * `{ ttlMs: 0, cacheScope: 'private' }` defaults (resources/list). The top + * rung — a handler-returned value winning over every configured hint — is + * pinned at unit level (encodeContract), not here. + * - the entryStateless cell (2025-11-25 axis): the same fully + * cache-hint-configured factory served to a plain client through the legacy + * stateless slot answers the same calls with none of that vocabulary + * anywhere in the response bytes. + * + * Both cells run through the wire() entry arms; the raw response bytes come + * from the arm-recorded `wired.httpLog`. + */ +import { Client } from '@modelcontextprotocol/client'; +import type { McpRequestContext } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; +import { z } from 'zod/v4'; + +import type { Wired } from '../helpers/index.js'; +import { wire } from '../helpers/index.js'; +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const LEGACY = '2025-11-25'; +const MODERN = '2026-07-28'; + +/** The cache-field vocabulary that must never appear on a 2025-era response. */ +const CACHE_VOCABULARY = ['"resultType"', '"ttlMs"', '"cacheScope"', '"cacheHint"'] as const; + +/** + * One ctx-taking factory with every cache-hint author configured: + * - a per-operation hint for tools/list (the funnel-built result with no other author), + * - a per-operation hint for resources/read AND a per-resource hint on the + * registered resource, so the documented precedence (per-resource wins) is + * observable on the wire. + */ +function cacheConfiguredFactory(_ctx?: McpRequestContext): McpServer { + const server = new McpServer( + { name: 'e2e-entry-cache', version: '1.0.0' }, + { + capabilities: { tools: {}, resources: {} }, + cacheHints: { + 'tools/list': { ttlMs: 60_000, cacheScope: 'public' }, + 'resources/read': { ttlMs: 90_000, cacheScope: 'public' } + } + } + ); + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name}` }] + })); + server.registerResource('note', 'memo://note', { cacheHint: { ttlMs: 12_000, cacheScope: 'private' } }, async uri => ({ + contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'cached note' }] + })); + return server; +} + +/** The raw response bodies of every recorded HTTP exchange, in order. */ +function responseBodies(wired: Wired): Promise { + return Promise.all((wired.httpLog ?? []).map(exchange => exchange.response.text())); +} + +/** Parses a captured response body (plain JSON or SSE-framed) into its JSON-RPC messages. */ +function jsonRpcMessagesFrom(text: string): Array> { + if (text.trim() === '') return []; + if (text.includes('data: ')) { + return text + .split('\n') + .filter(line => line.startsWith('data: ')) + .map(line => JSON.parse(line.slice(6)) as Record); + } + try { + const parsed = JSON.parse(text) as Record | Array>; + return Array.isArray(parsed) ? parsed : [parsed]; + } catch { + return []; + } +} + +/** Finds the wire result of the response message whose result carries the given key. */ +function wireResultWith(bodies: string[], key: string): Record | undefined { + for (const body of bodies) { + for (const message of jsonRpcMessagesFrom(body)) { + const result = message.result as Record | undefined; + if (result && key in result) return result; + } + } + return undefined; +} + +verifies('typescript:hosting:entry:modern-cacheable-stamping', async ({ transport }: TestArgs) => { + const client = new Client({ name: 'e2e-stamping-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await using wired = await wire(transport, cacheConfiguredFactory, client); + + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + // Typed round trips (the 2026 wire result schemas require the cache + // fields, so a successful decode is itself part of the assertion). + const list = await client.listTools(); + expect(list.tools.map(tool => tool.name)).toEqual(['greet']); + + const read = await client.readResource({ uri: 'memo://note' }); + const firstContent = read.contents[0]; + expect(firstContent && 'text' in firstContent ? firstContent.text : undefined).toBe('cached note'); + + const resourceList = await client.listResources(); + expect(resourceList.resources.map(resource => resource.uri)).toEqual(['memo://note']); + + // Wire-level: resultType is stamped and the cache fields carry the + // configured hints. tools/list has only the per-operation author (its + // hint wins over the defaults); resources/read shows the per-resource + // hint winning over the per-operation hint; resources/list has no + // configured author at all and is filled with the documented defaults. + const bodies = await responseBodies(wired); + const listResult = wireResultWith(bodies, 'tools'); + expect(listResult).toBeDefined(); + expect(listResult).toMatchObject({ resultType: 'complete', ttlMs: 60_000, cacheScope: 'public' }); + + const readResult = wireResultWith(bodies, 'contents'); + expect(readResult).toBeDefined(); + expect(readResult).toMatchObject({ resultType: 'complete', ttlMs: 12_000, cacheScope: 'private' }); + + const resourceListResult = wireResultWith(bodies, 'resources'); + expect(resourceListResult).toBeDefined(); + expect(resourceListResult).toMatchObject({ resultType: 'complete', ttlMs: 0, cacheScope: 'private' }); +}); + +verifies('typescript:hosting:entry:legacy-cacheable-suppression', async ({ transport }: TestArgs) => { + const client = new Client({ name: 'plain-2025-client', version: '1.0.0' }); + await using wired = await wire(transport, cacheConfiguredFactory, client); + + expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY); + + // The same calls, typed, on the 2025 leg (served through the legacy stateless slot). + const tools = await client.listTools(); + expect(tools.tools.map(tool => tool.name)).toEqual(['greet']); + const read = await client.readResource({ uri: 'memo://note' }); + const firstContent = read.contents[0]; + expect(firstContent && 'text' in firstContent ? firstContent.text : undefined).toBe('cached note'); + + // None of the 2026 cache vocabulary appears anywhere in the bytes of + // any response of this conversation, even though every cache-hint + // author is configured on the factory. + const bodies = await responseBodies(wired); + const conversation = bodies.join('\n'); + expect(conversation).toContain('"tools"'); + expect(conversation).toContain('"contents"'); + for (const term of CACHE_VOCABULARY) { + expect(conversation).not.toContain(term); + } +}); diff --git a/test/e2e/scenarios/hosting-entry-streaming.test.ts b/test/e2e/scenarios/hosting-entry-streaming.test.ts new file mode 100644 index 0000000000..6b6ec0c0cd --- /dev/null +++ b/test/e2e/scenarios/hosting-entry-streaming.test.ts @@ -0,0 +1,153 @@ +/** + * Modern-era (2026-07-28) response streaming through the dual-era HTTP entry, + * exercised on the wire() entryModern arm: + * + * - default response mode: a handler that emits nothing before its result is + * answered as a single JSON body; a handler that emits related notifications + * mid-call upgrades the response to an SSE stream (content-type + * text/event-stream, notifications framed in emission order, terminal result + * last); + * - `responseMode: 'sse'` always streams, even with no mid-call output; + * - `responseMode: 'json'` never streams and drops mid-call notifications — + * only the terminal result is delivered. + * + * Every body drives the harness-hosted entry with the auto-negotiating client; + * the typed result and the raw wire bytes (status, content-type, SSE frames) + * are asserted side by side via the arm-recorded `wired.httpLog`. + */ +import { Client } from '@modelcontextprotocol/client'; +import type { CallToolResult, McpRequestContext } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; +import { z } from 'zod/v4'; + +import type { Wired } from '../helpers/index.js'; +import { wire } from '../helpers/index.js'; +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const MODERN = '2026-07-28'; + +/** + * One factory with a quiet tool (no streamed output) and a chatty tool (two + * logging notifications emitted before its result), so the lazy upgrade and + * both forced response modes are observable per call. + */ +function streamingFactory(_ctx?: McpRequestContext): McpServer { + const server = new McpServer({ name: 'e2e-entry-streaming', version: '1.0.0' }, { capabilities: { tools: {}, logging: {} } }); + server.registerTool('quiet', { inputSchema: z.object({}) }, () => ({ + content: [{ type: 'text', text: 'quiet result' }] + })); + server.registerTool('chatty', { inputSchema: z.object({}) }, async (_args, ctx) => { + await ctx.mcpReq.notify({ method: 'notifications/message', params: { level: 'info', data: 'first' } }); + await ctx.mcpReq.notify({ method: 'notifications/message', params: { level: 'info', data: 'second' } }); + return { content: [{ type: 'text', text: 'chatty result' }] }; + }); + return server; +} + +interface RecordedResponse { + status: number; + contentType: string; + body: string; +} + +/** Every recorded HTTP response (status, content-type, raw body bytes), in exchange order. */ +function recordedResponses(wired: Wired): Promise { + return Promise.all( + (wired.httpLog ?? []).map(async exchange => ({ + status: exchange.status, + contentType: exchange.contentType, + body: await exchange.response.text() + })) + ); +} + +/** The `data:` payloads of an SSE-framed body, parsed, in frame order. */ +function sseDataFrames(body: string): Array> { + return body + .split('\n') + .filter(line => line.startsWith('data: ')) + .map(line => JSON.parse(line.slice('data: '.length)) as Record); +} + +function newAutoClient(): Client { + return new Client({ name: 'e2e-streaming-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); +} + +function callTool(client: Client, name: 'quiet' | 'chatty'): Promise { + return client.callTool({ name, arguments: {} }) as Promise; +} + +verifies('typescript:hosting:entry:modern-lazy-sse-upgrade', async ({ transport }: TestArgs) => { + const client = newAutoClient(); + await using wired = await wire(transport, streamingFactory, client); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + // Quiet handler: nothing emitted before the result → a single JSON body. + const quiet = await callTool(client, 'quiet'); + expect(quiet.content).toEqual([{ type: 'text', text: 'quiet result' }]); + + // Chatty handler: the first related notification upgrades the exchange + // to SSE — notifications framed in order, terminal result last. + const chatty = await callTool(client, 'chatty'); + expect(chatty.content).toEqual([{ type: 'text', text: 'chatty result' }]); + + const responses = await recordedResponses(wired); + const quietResponse = responses.find(response => response.body.includes('quiet result')); + expect(quietResponse).toBeDefined(); + expect(quietResponse!.status).toBe(200); + expect(quietResponse!.contentType).toContain('application/json'); + + const chattyResponse = responses.find(response => response.body.includes('chatty result')); + expect(chattyResponse).toBeDefined(); + expect(chattyResponse!.status).toBe(200); + expect(chattyResponse!.contentType).toContain('text/event-stream'); + + const frames = sseDataFrames(chattyResponse!.body); + expect(frames).toHaveLength(3); + expect(frames[0]).toMatchObject({ method: 'notifications/message', params: { data: 'first' } }); + expect(frames[1]).toMatchObject({ method: 'notifications/message', params: { data: 'second' } }); + expect(frames[2]).toMatchObject({ result: { content: [{ type: 'text', text: 'chatty result' }] } }); +}); + +verifies('typescript:hosting:entry:modern-response-mode', async ({ transport }: TestArgs) => { + // One harness-hosted endpoint per responseMode value, both backed by the same factory. + + // responseMode 'sse': even a handler that emits nothing streams its result. + { + const client = newAutoClient(); + await using wired = await wire(transport, streamingFactory, client, { entry: { responseMode: 'sse' } }); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + const result = await callTool(client, 'quiet'); + expect(result.content).toEqual([{ type: 'text', text: 'quiet result' }]); + + const responses = await recordedResponses(wired); + const response = responses.find(candidate => candidate.body.includes('quiet result')); + expect(response).toBeDefined(); + expect(response!.status).toBe(200); + expect(response!.contentType).toContain('text/event-stream'); + const frames = sseDataFrames(response!.body); + expect(frames).toHaveLength(1); + expect(frames[0]).toMatchObject({ result: { content: [{ type: 'text', text: 'quiet result' }] } }); + } + + // responseMode 'json': mid-call notifications are dropped — the response + // is a plain JSON body whose only payload is the terminal result. + { + const client = newAutoClient(); + await using wired = await wire(transport, streamingFactory, client, { entry: { responseMode: 'json' } }); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + const result = await callTool(client, 'chatty'); + expect(result.content).toEqual([{ type: 'text', text: 'chatty result' }]); + + const responses = await recordedResponses(wired); + const response = responses.find(candidate => candidate.body.includes('chatty result')); + expect(response).toBeDefined(); + expect(response!.status).toBe(200); + expect(response!.contentType).toContain('application/json'); + expect(response!.body).not.toContain('notifications/message'); + } +}); diff --git a/test/e2e/scenarios/hosting-entry.test.ts b/test/e2e/scenarios/hosting-entry.test.ts new file mode 100644 index 0000000000..e5b3cf08d8 --- /dev/null +++ b/test/e2e/scenarios/hosting-entry.test.ts @@ -0,0 +1,152 @@ +/** + * Core cells for the dual-era HTTP entry (`createMcpHandler`), exercised + * through the wire() entry arms: `entryStateless` hosts the entry's + * `legacy: 'stateless'` slot for plain 2025-era clients (2025-11-25 axis) and + * `entryModern` hosts the modern-only strict endpoint for negotiating clients + * (2026-07-28 axis). Raw wire facts (request bodies, statuses, response bytes) + * are asserted on the arm-recorded `wired.httpLog`; raw HTTP probes go through + * `wired.fetch` so every exchange still rides the harness-hosted entry. + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { McpRequestContext } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; +import { z } from 'zod/v4'; + +import { modernEnvelopeMeta, wire } from '../helpers/index.js'; +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const LEGACY = '2025-11-25'; +const MODERN = '2026-07-28'; + +/** One ctx-taking factory backing every cell: the era only shows up in the tool output so tests can see which leg served the call. */ +function greetFactory(ctx?: McpRequestContext): McpServer { + const server = new McpServer({ name: 'e2e-entry', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name} (${ctx?.era ?? 'unknown'})` }] + })); + return server; +} + +verifies('typescript:hosting:entry:dual-era-one-factory', async ({ transport }: TestArgs) => { + // Both cells host the same handler shape — one ctx-taking factory, legacy + // 'stateless' slot configured — and differ only in the client driving it. + const client = + transport === 'entryModern' + ? new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }) + : new Client({ name: 'plain-2025-client', version: '1.0.0' }); + await using wired = await wire(transport, greetFactory, client, { entry: { legacy: 'stateless' } }); + + if (transport === 'entryStateless') { + // 2025-era leg: a plain client is served per request through the + // legacy 'stateless' slot — initialize → tools/list → tools/call. + expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY); + const tools = await client.listTools(); + expect(tools.tools.map(tool => tool.name)).toEqual(['greet']); + const result = await client.callTool({ name: 'greet', arguments: { name: 'old friend' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hello old friend (legacy)' }]); + return; + } + + // 2026-era leg: the auto-negotiating client reaches 2026-07-28 via + // server/discover — never initialize — and tools/call is served with the + // per-request envelope (the modern factory leg answers, not the slot). + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + const requestBodies = () => (wired.httpLog ?? []).map(exchange => exchange.requestBody ?? ''); + // The "(never initialize)" clause of the requirement, asserted on the + // recorded wire traffic: no request body ever carried an initialize, + // and the negotiation rode server/discover. + expect(requestBodies().some(body => body.includes('"initialize"'))).toBe(false); + expect(requestBodies().some(body => body.includes('server/discover'))).toBe(true); + const result = await client.callTool({ name: 'greet', arguments: { name: 'new friend' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hello new friend (modern)' }]); + // ...and still no initialize anywhere on the wire after the tool call — + // the whole conversation rode the modern handshake. + expect(requestBodies().some(body => body.includes('"initialize"'))).toBe(false); +}); + +verifies('typescript:hosting:entry:pin-negotiation', async ({ transport }: TestArgs) => { + // Strict endpoint (no legacy slot — the entryModern arm default): the pinned client never needs one. + const client = new Client({ name: 'pin-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await using wired = await wire(transport, greetFactory, client); + + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + const requestBodies = () => (wired.httpLog ?? []).map(exchange => exchange.requestBody ?? ''); + // No initialize was ever put on the wire; the first request is the discover probe. + expect(requestBodies().some(body => body.includes('"initialize"'))).toBe(false); + expect(requestBodies()[0]).toContain('server/discover'); + + const result = await client.callTool({ name: 'greet', arguments: { name: 'pinned' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hello pinned (modern)' }]); + // The tool call rode the per-request envelope on the wire... + const callBody = requestBodies().find(body => body.includes('"tools/call"')); + expect(callBody).toBeDefined(); + expect(callBody).toContain(PROTOCOL_VERSION_META_KEY); + // ...and still no initialize anywhere on the wire after the tool call. + expect(requestBodies().some(body => body.includes('"initialize"'))).toBe(false); +}); + +verifies('typescript:hosting:entry:strict-rejects-legacy', async ({ transport }: TestArgs) => { + // legacy omitted → modern-only strict (the entryModern arm default): no silent 2025 serving. + const modernClient = new Client({ name: 'strict-modern-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await using wired = await wire(transport, greetFactory, modernClient); + + // The documented strict cell over plain HTTP: a 2025-shaped initialize is + // answered with the unsupported-protocol-version error naming the + // supported modern revisions (the numeric code is not pinned here). + const response = await wired.fetch!(wired.url!, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: LEGACY, capabilities: {}, clientInfo: { name: 'plain-2025-client', version: '1.0.0' } } + }) + }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: number; message: string; data?: { supported?: string[] } } }; + expect(body.error.message).toMatch(/unsupported protocol version/i); + expect(body.error.data?.supported).toContain(MODERN); + + // The plain SDK client sees the same rejection at connect time. + const plainClient = new Client({ name: 'plain-2025-client', version: '1.0.0' }); + try { + await expect(plainClient.connect(new StreamableHTTPClientTransport(wired.url!, { fetch: wired.fetch }))).rejects.toThrow( + /Unsupported protocol version|400/ + ); + } finally { + await plainClient.close().catch(() => {}); + } +}); + +verifies('typescript:hosting:entry:notification-202', async ({ transport }: TestArgs) => { + const client = new Client({ name: 'notify-client', version: '1.0.0' }); + await using wired = await wire(transport, greetFactory, client, { entry: { legacy: 'stateless' } }); + + // 2025 leg: an envelope-less notification rides the legacy stateless slot. + // 2026 leg: the notification carries the per-request envelope and a method + // the 2026-07-28 registry defines. + const notification = + transport === 'entryStateless' + ? { jsonrpc: '2.0', method: 'notifications/initialized' } + : { + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { + requestId: 'never-issued', + reason: 'probe', + _meta: modernEnvelopeMeta({ name: 'notify-client', version: '1.0.0' }) + } + }; + + const response = await wired.fetch!(wired.url!, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(notification) + }); + expect(response.status).toBe(202); + expect(await response.text()).toBe(''); +}); diff --git a/test/e2e/scenarios/hosting-express.test.ts b/test/e2e/scenarios/hosting-express.test.ts index fbc9851c5e..f4a3141a78 100644 --- a/test/e2e/scenarios/hosting-express.test.ts +++ b/test/e2e/scenarios/hosting-express.test.ts @@ -24,7 +24,7 @@ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/cli import { createMcpExpressApp, mcpAuthMetadataRouter, requireBearerAuth } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { OAuthMetadata } from '@modelcontextprotocol/server'; -import { McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +import { LATEST_PROTOCOL_VERSION, McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; import type { Express, RequestHandler } from 'express'; import express from 'express'; import { expect } from 'vitest'; @@ -475,13 +475,16 @@ verifies('hosting:express:adapter-host-header-validation', async ({ protocolVers expect(mcpRouteHits).toBe(0); // Control: the identical request with the real localhost Host reaches the transport and initializes normally. + // The negotiated version follows initialize semantics: a 2026-era request is answered with the latest legacy + // version (2026-era revisions are never negotiated via initialize); legacy requests are echoed back. + const negotiatedVersion = protocolVersion >= '2026-07-28' ? LATEST_PROTOCOL_VERSION : protocolVersion; const allowed = await postWithHost(new URL('/mcp', baseUrl), `127.0.0.1:${baseUrl.port}`, initializeBody); expect(allowed.status).toBe(200); const allowedJson: unknown = JSON.parse(allowed.body); expect(allowedJson).toMatchObject({ jsonrpc: '2.0', id: 1, - result: { protocolVersion, serverInfo: { name: 'rebind-protected-server', version: '1.0.0' } } + result: { protocolVersion: negotiatedVersion, serverInfo: { name: 'rebind-protected-server', version: '1.0.0' } } }); expect(mcpRouteHits).toBe(1); } finally { diff --git a/test/e2e/scenarios/protocol.test.ts b/test/e2e/scenarios/protocol.test.ts index 40b5a20af3..4cb0406763 100644 --- a/test/e2e/scenarios/protocol.test.ts +++ b/test/e2e/scenarios/protocol.test.ts @@ -1535,16 +1535,40 @@ class LoopbackTransport implements Transport { this.events.push('method' in message ? `send:${message.method}` : 'send:response'); if (!isRequest(message)) return; this.clientRequests.push(message); - if (message.method === 'initialize') { - this.respond(message.id, { - protocolVersion: this.serverProtocolVersion, - capabilities: { tools: {} }, - serverInfo: { name: 'loopback-server', version: '3.1.4' } - }); - } else if (message.method === 'tools/list') { - this.respond(message.id, { - tools: [{ name: 'lookup_order', description: 'Look up an order by id', inputSchema: { type: 'object' } }] - }); + const modern = this.serverProtocolVersion >= '2026-07-28'; + switch (message.method) { + case 'initialize': { + this.respond(message.id, { + protocolVersion: this.serverProtocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: 'loopback-server', version: '3.1.4' } + }); + break; + } + case 'server/discover': { + // The 2026-era handshake: advertise the canned identity instead of + // answering an initialize exchange. + this.respond(message.id, { + supportedVersions: [this.serverProtocolVersion], + capabilities: { tools: {} }, + serverInfo: { name: 'loopback-server', version: '3.1.4' } + }); + break; + } + case 'tools/list': { + const tools = [{ name: 'lookup_order', description: 'Look up an order by id', inputSchema: { type: 'object' } }]; + this.respond( + message.id, + modern + ? // The 2026 wire shape carries the result discriminator and the cacheable-result fields. + ({ resultType: 'complete', ttlMs: 0, cacheScope: 'public', tools } as unknown as Result) + : { tools } + ); + break; + } + default: { + break; + } } } @@ -1560,29 +1584,38 @@ class LoopbackTransport implements Transport { verifies('transport:custom:client-connect', async ({ protocolVersion }: TestArgs) => { // The body supplies its own consumer-implemented Transport, so the matrix transport arg is unused by design. + // On 2025-era cells the handshake is the plain initialize exchange; on 2026-era cells it is the + // server/discover negotiation (a 2026 revision is never negotiated via initialize), which the client opts + // into by pinning the cell's revision. + const modern = protocolVersion >= '2026-07-28'; const customTransport = new LoopbackTransport(protocolVersion); - const client = newClient(); + const client = modern + ? new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: protocolVersion } } }) + : newClient(); const clientOnclose = vi.fn(); client.onclose = clientOnclose; + const handshake = modern ? ['send:server/discover'] : ['send:initialize', 'send:notifications/initialized']; + const handshakeRequests = modern ? ['server/discover'] : ['initialize']; try { await client.connect(customTransport); - // Protocol installed its callbacks on the consumer object before invoking start(). + // Connect installed callbacks on the consumer object before invoking start(). expect(customTransport.callbacksPresentAtStart).toEqual({ onmessage: true, onclose: true, onerror: true }); // The full handshake ran over the consumer transport, and its canned identity is what the client now reports. - expect(customTransport.events).toEqual(['start', 'send:initialize', 'send:notifications/initialized']); + expect(customTransport.events).toEqual(['start', ...handshake]); expect(client.getServerCapabilities()).toEqual({ tools: {} }); expect(client.getServerVersion()).toEqual({ name: 'loopback-server', version: '3.1.4' }); + expect(client.getNegotiatedProtocolVersion()).toBe(protocolVersion); // A post-handshake request round-trips through the consumer transport's send(). const listed = await client.listTools(); expect(listed.tools).toEqual([{ name: 'lookup_order', description: 'Look up an order by id', inputSchema: { type: 'object' } }]); - expect(customTransport.clientRequests.map(m => m.method)).toEqual(['initialize', 'tools/list']); + expect(customTransport.clientRequests.map(m => m.method)).toEqual([...handshakeRequests, 'tools/list']); await client.close(); // close() reached the consumer transport, and its onclose callback fed back into the client's close handling. - expect(customTransport.events).toEqual(['start', 'send:initialize', 'send:notifications/initialized', 'send:tools/list', 'close']); + expect(customTransport.events).toEqual(['start', ...handshake, 'send:tools/list', 'close']); expect(clientOnclose).toHaveBeenCalledTimes(1); expect(client.transport).toBeUndefined(); } finally { diff --git a/test/e2e/scenarios/raw-result-type.test.ts b/test/e2e/scenarios/raw-result-type.test.ts new file mode 100644 index 0000000000..ee5b454763 --- /dev/null +++ b/test/e2e/scenarios/raw-result-type.test.ts @@ -0,0 +1,179 @@ +/** + * Raw-first result discrimination through the full client path — ERA-SCOPED + * (Q1 increment 2: V-1 lives in the era codec's decodeResult, and the + * postures are ruled per era by Q1-SD3). + * + * A raw relay server (no SDK Server involved) answers tools/call with hand + * built bodies. The negotiated protocol version selects the wire era; the + * modern arms negotiate it through the real path (versionNegotiation + + * server/discover — a 2026 revision is never negotiated via initialize): + * + * - Negotiated 2026-07-28: `resultType` is the REQUIRED discriminator. An + * `input_required` body surfaces the discriminated kind as a typed local + * error (the multi-round-trip driver consumes it when it lands); an + * ABSENT `resultType` is a spec violation surfaced as a typed error + * naming it. + * - Negotiated legacy (2025 era): `resultType` is FOREIGN vocabulary — + * strip-on-lift (Q1-SD3 ii; a deliberate, ledgered change from the + * pre-split era-blind rejection — changeset: codec-split-wire-break). The + * stripped body then fails the (default-free) result schema loudly + * because it has no content. + * + * Either way the V-1 invariant holds: never an empty-content success. + */ +import { Client, SdkError, SdkErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import type { JSONRPCRequest } from '@modelcontextprotocol/server'; +import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; + +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const INPUT_REQUIRED_BODY = { + resultType: 'input_required', + inputRequests: { + 'elicit-1': { + method: 'elicitation/create', + params: { mode: 'form', message: 'What is your name?', requestedSchema: { type: 'object', properties: {} } } + } + }, + requestState: 'opaque-state' +}; + +/** A complete-looking body that omits the (2026-required) resultType. */ +const ABSENT_RESULT_TYPE_BODY = { content: [{ type: 'text', text: 'looks complete' }] }; + +function initializeResult(requestedVersion: string) { + return { + protocolVersion: requestedVersion, + capabilities: { tools: {} }, + serverInfo: { name: 'raw-input-required-server', version: '0' } + }; +} + +function makeResponder(toolCallBody: unknown) { + return function respondTo(request: JSONRPCRequest): unknown { + if (request.method === 'initialize') { + const requested = (request.params as { protocolVersion?: string } | undefined)?.protocolVersion ?? LATEST_PROTOCOL_VERSION; + return initializeResult(requested); + } + if (request.method === 'server/discover') { + // The modern handshake: the relay advertises the draft revision so a + // negotiating client selects it (no initialize on that path). + return { + supportedVersions: ['2026-07-28'], + capabilities: { tools: {} }, + serverInfo: { name: 'raw-input-required-server', version: '0' } + }; + } + if (request.method === 'tools/call') return toolCallBody; + return {}; + }; +} + +async function connectInMemory(client: Client, toolCallBody: unknown): Promise { + const respondTo = makeResponder(toolCallBody); + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = message => { + const request = message as JSONRPCRequest; + if (request.id === undefined) return; // notifications need no answer + void serverTx.send({ jsonrpc: '2.0', id: request.id, result: respondTo(request) } as Parameters[0]); + }; + await serverTx.start(); + await client.connect(clientTx); +} + +async function connectStreamableHttp(client: Client, toolCallBody: unknown): Promise { + const respondTo = makeResponder(toolCallBody); + // A hand HTTP handler (no SDK server): JSON responses, 202 for notifications. + const fetchHandler = async (input: URL | string, init?: RequestInit): Promise => { + const request = new Request(input, init); + if (request.method !== 'POST') return new Response(null, { status: 405 }); + const body = (await request.json()) as JSONRPCRequest | JSONRPCRequest[]; + const message = Array.isArray(body) ? body[0] : body; + if (message?.id === undefined) return new Response(null, { status: 202 }); + return Response.json({ jsonrpc: '2.0', id: message.id, result: respondTo(message) }); + }; + await client.connect(new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch: fetchHandler })); +} + +async function callToolOutcome(client: Client): Promise<{ resolved: unknown } | { rejected: unknown }> { + return client.callTool({ name: 'anything', arguments: {} }).then( + result => ({ resolved: result as unknown }), + error => ({ rejected: error as unknown }) + ); +} + +verifies('typescript:client:raw-result-type-first', async ({ transport }: TestArgs) => { + // ---- Legacy negotiation (the relay echoes the client's default offer, + // so this connection negotiates a legacy version → 2025 era). ---- + { + const client = new Client({ name: 'raw-result-type-client', version: '0' }); + await (transport === 'inMemory' + ? connectInMemory(client, INPUT_REQUIRED_BODY) + : connectStreamableHttp(client, INPUT_REQUIRED_BODY)); + + try { + const outcome = await callToolOutcome(client); + // Strip-on-lift (Q1-SD3 ii, ledgered): the foreign resultType is + // dropped; the body has no content, so validation fails LOUDLY. + // Never an empty-content success. + expect('resolved' in outcome, `must not resolve: ${JSON.stringify(outcome)}`).toBe(false); + const rejection = (outcome as { rejected: unknown }).rejected; + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.InvalidResult); + } finally { + await client.close(); + } + } + + // ---- Modern negotiation: the client pins the draft revision, the relay + // advertises it via server/discover → 2026 era → V-1 discrimination in + // the codec. ---- + { + const client = new Client( + { name: 'raw-result-type-client', version: '0' }, + { versionNegotiation: { mode: { pin: '2026-07-28' } } } + ); + await (transport === 'inMemory' + ? connectInMemory(client, INPUT_REQUIRED_BODY) + : connectStreamableHttp(client, INPUT_REQUIRED_BODY)); + + try { + const outcome = await callToolOutcome(client); + expect('resolved' in outcome, `must not resolve: ${JSON.stringify(outcome)}`).toBe(false); + const rejection = (outcome as { rejected: unknown }).rejected; + expect(rejection).toBeInstanceOf(SdkError); + const typed = rejection as SdkError; + expect(typed.code).toBe(SdkErrorCode.UnsupportedResultType); + expect(typed.data).toMatchObject({ resultType: 'input_required', method: 'tools/call' }); + } finally { + await client.close(); + } + } + + // ---- Modern negotiation, absent resultType: the spec violation is + // surfaced as a typed error naming it (Q1-SD3 i — the absent⇒complete + // bridge applies only to earlier-revision servers). ---- + { + const client = new Client( + { name: 'raw-result-type-client', version: '0' }, + { versionNegotiation: { mode: { pin: '2026-07-28' } } } + ); + await (transport === 'inMemory' + ? connectInMemory(client, ABSENT_RESULT_TYPE_BODY) + : connectStreamableHttp(client, ABSENT_RESULT_TYPE_BODY)); + + try { + const outcome = await callToolOutcome(client); + expect('resolved' in outcome, `must not resolve: ${JSON.stringify(outcome)}`).toBe(false); + const rejection = (outcome as { rejected: unknown }).rejected; + expect(rejection).toBeInstanceOf(SdkError); + const typed = rejection as SdkError; + expect(typed.code).toBe(SdkErrorCode.InvalidResult); + expect(String(typed.message)).toContain('missing required resultType'); + } finally { + await client.close(); + } + } +}); diff --git a/test/e2e/scenarios/stdio-dual-era.test.ts b/test/e2e/scenarios/stdio-dual-era.test.ts new file mode 100644 index 0000000000..46a2406ea1 --- /dev/null +++ b/test/e2e/scenarios/stdio-dual-era.test.ts @@ -0,0 +1,88 @@ +/** + * Self-contained test bodies for dual-era stdio serving. + * + * Like the other transport:stdio scenarios these do not use `wire()`: each + * body spawns the dual-era fixture server in + * `fixtures/dual-era-stdio-server.ts` (eraSupport: 'dual-era', unchanged + * StdioServerTransport) as a real child process via {@link StdioClientTransport}. + * The matrix `transport` arg is ignored (the requirement lists + * `transports: ['stdio']`); the spec-version axis selects which client drives + * the cell — a plain 2025 client over `initialize`, or the auto-negotiating + * client reaching 2026-07-28 over `server/discover` on the same kind of pipe. + */ + +import { fileURLToPath } from 'node:url'; + +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; + +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +/** Absolute path to the runnable dual-era fixture server (executed with tsx). */ +const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/dual-era-stdio-server.ts', import.meta.url)); + +/** E2E package root — spawn cwd so node/tsx resolve the local toolchain and workspace packages. */ +const E2E_ROOT = fileURLToPath(new URL('../', import.meta.url)); + +const MODERN = '2026-07-28'; + +verifies('typescript:transport:stdio:dual-era-serving', async ({ protocolVersion }: TestArgs) => { + const transport = new StdioClientTransport({ + command: process.execPath, + args: ['--import', 'tsx', FIXTURE_PATH], + cwd: E2E_ROOT + }); + + if (protocolVersion === '2025-11-25') { + // Legacy leg: a plain 2025 client is served via initialize, exactly as + // against an undeclared server. + const client = new Client({ name: 'plain-2025-client', version: '0' }); + try { + await client.connect(transport); + expect(client.getNegotiatedProtocolVersion()).toBe(protocolVersion); + const result = await client.callTool({ name: 'echo', arguments: { text: 'legacy leg' } }); + expect(result.isError).toBeFalsy(); + expect(result.content).toEqual([{ type: 'text', text: 'legacy leg' }]); + } finally { + await client.close(); + await transport.close(); + } + return; + } + + // Modern leg: the auto-negotiating client reaches 2026-07-28 via + // server/discover on the pipe (no initialize is ever written) and + // tools/call round-trips with the per-request envelope. + const sentMethods: string[] = []; + const originalSend = transport.send.bind(transport); + transport.send = async message => { + if ('method' in message) sentMethods.push(message.method); + return originalSend(message); + }; + + const client = new Client({ name: 'auto-client', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + try { + await client.connect(transport); + expect(client.getNegotiatedProtocolVersion()).toBe(protocolVersion); + expect(sentMethods).not.toContain('initialize'); + expect(sentMethods[0]).toBe('server/discover'); + + const envelope = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'auto-client', version: '0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; + const result = (await client.request({ + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'modern leg' }, _meta: envelope } + })) as CallToolResult; + expect(result.content).toEqual([{ type: 'text', text: 'modern leg' }]); + } finally { + await client.close(); + await transport.close(); + } +}); diff --git a/test/e2e/scenarios/transport-raw.test.ts b/test/e2e/scenarios/transport-raw.test.ts index 5645df0181..bef7300943 100644 --- a/test/e2e/scenarios/transport-raw.test.ts +++ b/test/e2e/scenarios/transport-raw.test.ts @@ -17,7 +17,12 @@ import { fileURLToPath } from 'node:url'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -import { CallToolResultSchema, InitializeResultSchema, JSONRPCResultResponseSchema } from '@modelcontextprotocol/core'; +import { + CallToolResultSchema, + InitializeResultSchema, + JSONRPCResultResponseSchema, + LATEST_PROTOCOL_VERSION +} from '@modelcontextprotocol/core'; import type { JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/server'; import { InMemoryTransport, McpServer } from '@modelcontextprotocol/server'; import { expect, vi } from 'vitest'; @@ -45,6 +50,17 @@ function initializeRequest(id: number, protocolVersion: string): JSONRPCRequest const INITIALIZED_NOTIFICATION: JSONRPCNotification = { jsonrpc: '2.0', method: 'notifications/initialized' }; +/** + * The protocol version a real SDK server negotiates for a raw `initialize` + * naming `requested`: 2026-era revisions are never negotiated via the legacy + * initialize handshake (they are only selected through `server/discover`), so + * the server answers with its latest legacy version instead of echoing the + * request. + */ +function expectedNegotiatedVersion(requested: string): string { + return requested >= '2026-07-28' ? LATEST_PROTOCOL_VERSION : requested; +} + /** Hand-built tools/call request for the echo tool exposed by both real servers used below. */ function echoCallRequest(id: number): JSONRPCRequest { return { jsonrpc: '2.0', id, method: 'tools/call', params: { name: 'echo', arguments: { text: 'relayed raw' } } }; @@ -158,7 +174,12 @@ async function rawRelayStdio(protocolVersion: string): Promise { await transport.send(initializeRequest(1, protocolVersion)); // Generous first wait: tsx compiles the fixture inside the freshly spawned child before it can answer. await vi.waitFor(() => expect(received).toHaveLength(1), { timeout: 10_000, interval: 25 }); - expectInitializeResponse(defined(received[0], 'initialize response'), 1, protocolVersion, 'stdio-echo-server'); + expectInitializeResponse( + defined(received[0], 'initialize response'), + 1, + expectedNegotiatedVersion(protocolVersion), + 'stdio-echo-server' + ); // Forward the rest of a relay's traffic by hand: initialized notification, then a tools/call. await transport.send(INITIALIZED_NOTIFICATION); @@ -206,7 +227,12 @@ async function rawRelayStreamableHttp(protocolVersion: string, stateless: boolea expect(records).toEqual([{ method: 'POST' }]); await vi.waitFor(() => expect(received).toHaveLength(1), { timeout: 5000, interval: 10 }); - expectInitializeResponse(defined(received[0], 'initialize response'), 1, protocolVersion, 'raw-relay-http-server'); + expectInitializeResponse( + defined(received[0], 'initialize response'), + 1, + expectedNegotiatedVersion(protocolVersion), + 'raw-relay-http-server' + ); // Forward the rest of a relay's traffic by hand: initialized notification, then a tools/call. await transport.send(INITIALIZED_NOTIFICATION); diff --git a/test/e2e/types.ts b/test/e2e/types.ts index c7ff6bdd80..8887f60322 100644 --- a/test/e2e/types.ts +++ b/test/e2e/types.ts @@ -2,9 +2,28 @@ * Shared types for the e2e suite. */ -export const ALL_TRANSPORTS = ['inMemory', 'stdio', 'streamableHttp', 'streamableHttpStateless', 'sse'] as const; +export const ALL_TRANSPORTS = [ + 'inMemory', + 'stdio', + 'streamableHttp', + 'streamableHttpStateless', + 'sse', + 'entryStateless', + 'entryModern' +] as const; export type Transport = (typeof ALL_TRANSPORTS)[number]; +/** + * The createMcpHandler entry arms: the dual-era HTTP entry hosted in process + * (injected fetch → `handler.fetch`), one arm per slot. `entryStateless` serves + * a plain 2025-era client through the entry's `legacy: 'stateless'` slot; + * `entryModern` serves a client that negotiates the 2026-07-28 revision through + * the entry's modern (per-request envelope) path. Each arm is era-fixed, so it + * registers cells on exactly one spec-version axis (see TRANSPORT_SPEC_VERSIONS). + */ +export const ENTRY_TRANSPORTS = ['entryStateless', 'entryModern'] as const satisfies readonly Transport[]; +export type EntryTransport = (typeof ENTRY_TRANSPORTS)[number]; + /** * Every spec version the manifest may reference — used for typing * `addedInSpecVersion` / `removedInSpecVersion` bounds and knownFailure @@ -14,7 +33,20 @@ export const KNOWN_SPEC_VERSIONS = ['2025-11-25', '2026-07-28'] as const; export type SpecVersion = (typeof KNOWN_SPEC_VERSIONS)[number]; /** The spec versions cells are registered for (the active matrix axis). */ -export const ALL_SPEC_VERSIONS = ['2025-11-25'] as const satisfies readonly SpecVersion[]; +export const ALL_SPEC_VERSIONS = ['2025-11-25', '2026-07-28'] as const satisfies readonly SpecVersion[]; + +/** + * Spec versions a transport arm can serve. Transports without an entry serve + * every spec version on the active axis; the entry arms are era-fixed (the + * `legacy: 'stateless'` slot serves only 2025-era traffic, the modern path + * serves only the 2026-07-28 revision), so each registers cells on exactly one + * axis. `verifies()` intersects this with a requirement's own spec-version + * bounds when forming cells. + */ +export const TRANSPORT_SPEC_VERSIONS: Partial> = { + entryStateless: ['2025-11-25'], + entryModern: ['2026-07-28'] +}; /** * Arguments every test body receives. Expand with new matrix axes here so @@ -32,6 +64,57 @@ export interface KnownFailure { note: string; } +/** + * Machine-readable reasons a requirement is excluded from the createMcpHandler + * entry arms. The exclusion list doubles as the acceptance checklist for the + * entry features that have not landed yet: when one of them lands, its + * reason's entries are the cells to re-admit. (Requirement families that the + * per-request entry structurally cannot serve at all — server→client requests, + * sessions/resumability, standalone GET streams, subscriptions — are already + * expressed through their existing `transports` restrictions and never reach + * the entry arms, so they need no annotation here.) + * + * - `requires-session` — needs a persistent connected server instance (or + * connection-level message delivery beyond one request/response exchange); + * the entry's modern path serves every request with a fresh instance. + * - `method-not-in-modern-registry` — drives a method the 2026-07-28 registry + * deletes (ping, logging/setLevel, resources/subscribe, + * notifications/roots/list_changed, …); meaningful only for `entryModern`. + * - `asserts-legacy-handshake` — asserts initialize/initialized handshake or + * initialize-based version-negotiation mechanics; the modern path negotiates + * via server/discover and never sends initialize, so the body would assert + * vacuously or fail. Meaningful only for `entryModern`. + * - `legacy-only-vocabulary` — asserts wire vocabulary or advertisement flags + * the 2026-07-28 surface deliberately deletes or omits (tools[].execution, + * listChanged/subscribe capability flags on server/discover). Meaningful + * only for `entryModern`. + * - `modern-error-surface` — asserts the 2025-era client-facing error surface + * (ProtocolError with the wire code) for dispatch-window errors; on the + * modern per-request path those errors ride mapped HTTP statuses and the + * client currently surfaces them as SdkHttpError (see the coverage report's + * GAPS FOUND). Meaningful only for `entryModern`. + * - `drives-transport-directly` — the body builds and drives its own transport + * or hosting instead of the wired pair, so an entry cell would duplicate an + * existing cell without exercising the entry. + */ +export const ENTRY_EXCLUSION_REASONS = [ + 'requires-session', + 'method-not-in-modern-registry', + 'asserts-legacy-handshake', + 'legacy-only-vocabulary', + 'modern-error-surface', + 'drives-transport-directly' +] as const; +export type EntryExclusionReason = (typeof ENTRY_EXCLUSION_REASONS)[number]; + +export interface EntryExclusion { + /** The entry arm excluded; omit to exclude both arms. */ + arm?: EntryTransport; + reason: EntryExclusionReason; + /** Optional elaboration beyond the machine-readable reason. */ + note?: string; +} + export interface Requirement { source: string; behavior: string; @@ -39,6 +122,15 @@ export interface Requirement { /** Free-form rationale for how the entry is set up (e.g. why certain transports are excluded). */ note?: string; + /** + * Exclusions from the createMcpHandler entry arms (`entryStateless` / + * `entryModern`), each with a machine-readable reason. Only meaningful when + * the requirement's transports would otherwise include the targeted arm + * (the default `ALL_TRANSPORTS` does); an explicit `transports` list that + * already omits the entry arms needs no annotation here. + */ + entryExclusions?: readonly EntryExclusion[]; + /** First / last spec versions a requirement applies to; changed behaviors are sibling entries linked via `supersedes`/`supersededBy`. */ addedInSpecVersion?: SpecVersion; removedInSpecVersion?: SpecVersion; diff --git a/test/integration/test/__fixtures__/dualEraStdioServer.ts b/test/integration/test/__fixtures__/dualEraStdioServer.ts new file mode 100644 index 0000000000..46499f6156 --- /dev/null +++ b/test/integration/test/__fixtures__/dualEraStdioServer.ts @@ -0,0 +1,29 @@ +/** + * A dual-era stdio server fixture: `eraSupport: 'dual-era'` on an otherwise + * ordinary hand-constructed McpServer connected to the unchanged + * StdioServerTransport. Spawned as a real child process by + * `test/server/dualEraStdio.test.ts`. + */ +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +const server = new McpServer( + { name: 'dual-era-stdio-fixture', version: '1.0.0' }, + { capabilities: { tools: {} }, instructions: 'dual-era stdio fixture', eraSupport: 'dual-era' } +); + +server.registerTool('echo', { description: 'Echoes the input text', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] +})); + +await server.connect(new StdioServerTransport()); + +const exit = async () => { + await server.close(); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); +}; + +process.on('SIGINT', exit); +process.on('SIGTERM', exit); diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index a7613b24e4..5b833de3eb 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -171,6 +171,67 @@ test('should restore negotiated protocol version on transport when reconnecting expect(reconnectSetProtocolVersion).toHaveBeenCalledWith(LATEST_PROTOCOL_VERSION); }); +/*** + * Test: The negotiated protocol version (and with it the wire era) is connection state — it must + * not survive into a fresh connect. A client whose previous connection negotiated the modern + * revision (2026-07-28) via server/discover must still be able to run a FRESH legacy initialize + * handshake: `initialize` is legacy-era vocabulary by definition (it is physically absent from + * the modern registry), so a negotiated version left over from the dead connection would + * otherwise kill the handshake locally before it reaches the transport. + * + * The modern era is reached through the real negotiation path (versionNegotiation + the + * server/discover probe) — never via initialize, which only negotiates legacy versions. + */ +test('should run a fresh initialize handshake after close() when the previous connection negotiated the modern era', async () => { + const MODERN_REVISION = '2026-07-28'; + const supportedProtocolVersions = [MODERN_REVISION, ...SUPPORTED_PROTOCOL_VERSIONS]; + + const connectModern = async (client: Client) => { + // Serving a 2026-era revision on a hand-constructed instance is a declared act + // (eraSupport); a dual-era instance answers the client's server/discover probe + // per message with no instance binding. + const server = new Server( + { name: 'modern server', version: '1.0' }, + { capabilities: {}, supportedProtocolVersions, eraSupport: 'dual-era' } + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + }; + + const connectLegacy = async (client: Client) => { + const server = new Server({ name: 'legacy server', version: '1.0' }, { capabilities: {} }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + }; + + // The client opts into negotiation: server/discover probe first, legacy initialize fallback. + const client = new Client({ name: 'test client', version: '1.0' }, { supportedProtocolVersions, versionNegotiation: { mode: 'auto' } }); + + // First connection negotiates the modern revision via server/discover: the instance now + // speaks the modern wire era. + await connectModern(client); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN_REVISION); + + await client.close(); + + // Fresh connect (new transport, no sessionId): the stale negotiated version is cleared and + // the connection re-negotiates from scratch — modern again here. + await connectModern(client); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN_REVISION); + + await client.close(); + + // A fresh connect against a legacy-only server still runs the legacy initialize fallback: + // a leftover modern negotiated version would kill `initialize` locally (it is physically + // absent from the modern registry). + await connectLegacy(client); + expect(client.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + + await client.close(); +}); + /*** * Test: Reject Unsupported Protocol Version */ @@ -1769,6 +1830,9 @@ describe('outputSchema validation', () => { server.setRequestHandler('tools/call', async request => { if (request.params.name === 'test-tool') { return { + // content is spec-required (the wire default([]) was removed + // - ledgered; changeset codec-split-wire-break) + content: [], structuredContent: { result: 'success', count: 42 } }; } @@ -1844,6 +1908,7 @@ describe('outputSchema validation', () => { if (request.params.name === 'test-tool') { // Return invalid structured content (count is string instead of number) return { + content: [], structuredContent: { result: 'success', count: 'not a number' } }; } @@ -2071,6 +2136,7 @@ describe('outputSchema validation', () => { server.setRequestHandler('tools/call', async request => { if (request.params.name === 'complex-tool') { return { + content: [], structuredContent: { name: 'John Doe', age: 30, @@ -2156,6 +2222,7 @@ describe('outputSchema validation', () => { if (request.params.name === 'strict-tool') { // Return structured content with extra property return { + content: [], structuredContent: { name: 'John', extraField: 'not allowed' diff --git a/test/integration/test/client/discoverRoundtrip.test.ts b/test/integration/test/client/discoverRoundtrip.test.ts new file mode 100644 index 0000000000..14b88a7cf2 --- /dev/null +++ b/test/integration/test/client/discoverRoundtrip.test.ts @@ -0,0 +1,164 @@ +/** + * Discover round-trip: a pin-mode 2026 client completes `server/discover` → + * version selection against a modern server over real HTTP, plus the + * era-aware counter-offer end to end (a legacy client against a server whose + * supported list carries a 2026 revision never sees a 2026 version string). + * + * Serving a 2026-era revision on a hand-constructed instance is a declared + * act: the servers under test pass `eraSupport: 'dual-era'` (a modern + * revision in the supported list without that declaration is a + * construction-time TypeError), and a dual-era instance answers the + * `server/discover` probe per message with no instance binding. + */ +import type { Server as HttpServer } from 'node:http'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { SdkError, SdkErrorCode, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { McpServer } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +const MODERN = '2026-07-28'; +const DUAL_ERA_VERSIONS = [MODERN, ...SUPPORTED_PROTOCOL_VERSIONS]; + +function recordingFetch() { + const bodies: string[] = []; + const fetchFn: typeof fetch = async (input, init) => { + if (typeof init?.body === 'string') bodies.push(init.body); + return fetch(input, init); + }; + return { bodies, fetchFn }; +} + +describe('server/discover round-trip against a modern server', () => { + const cleanups: Array<() => Promise | void> = []; + afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!(); + }); + + async function startServer(options: { kind: 'dual-era' | 'legacy-only' }) { + const httpServer: HttpServer = createServer(); + const mcpServer = new McpServer( + { name: 'dual-era-server', version: '2.0.0' }, + { + capabilities: { tools: { listChanged: true } }, + instructions: 'dual era', + ...(options.kind === 'dual-era' ? { supportedProtocolVersions: DUAL_ERA_VERSIONS, eraSupport: 'dual-era' as const } : {}) + } + ); + mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + const serverTransport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await mcpServer.connect(serverTransport); + httpServer.on('request', (req, res) => void serverTransport.handleRequest(req, res)); + const baseUrl = await listenOnRandomPort(httpServer); + cleanups.push(async () => { + await mcpServer.close().catch(() => {}); + await serverTransport.close().catch(() => {}); + httpServer.close(); + }); + return baseUrl; + } + + it('pin-mode 2026 client: server/discover → version selection, no initialize ever sent', async () => { + const baseUrl = await startServer({ kind: 'dual-era' }); + const { bodies, fetchFn } = recordingFetch(); + + const client = new Client({ name: 'pin-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(new StreamableHTTPClientTransport(baseUrl, { fetch: fetchFn })); + cleanups.push(() => client.close()); + + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + expect(client.getServerVersion()).toEqual({ name: 'dual-era-server', version: '2.0.0' }); + expect(client.getInstructions()).toBe('dual era'); + // The advertisement excludes listChanged-class capabilities, visible end to end. + expect(client.getServerCapabilities()).toEqual({ tools: {} }); + + expect(bodies.some(b => b.includes('"initialize"'))).toBe(false); + expect(bodies[0]).toContain('server/discover'); + }); + + it('auto-mode client selects the modern era on the same server', async () => { + const baseUrl = await startServer({ kind: 'dual-era' }); + const client = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(baseUrl)); + cleanups.push(() => client.close()); + + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + }); + + it('auto-mode against a server that has not opted into modern-era support falls back to the legacy handshake', async () => { + // A hand-constructed server with the default eraSupport never serves + // server/discover: the probe is answered -32601 and the client falls + // back cleanly on the same connection. + const baseUrl = await startServer({ kind: 'legacy-only' }); + const client = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(baseUrl)); + cleanups.push(() => client.close()); + + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + const result = await client.callTool({ name: 'echo', arguments: { text: 'fallback' } }); + expect(result.content).toEqual([{ type: 'text', text: 'fallback' }]); + }); + + it('a plain legacy client against a dual-era server never meets a 2026 version string (counter-offer ordering, e2e)', async () => { + const baseUrl = await startServer({ kind: 'dual-era' }); + const { fetchFn } = recordingFetch(); + + const responses: string[] = []; + const sniffingFetch: typeof fetch = async (input, init) => { + const response = await fetchFn(input, init); + responses.push( + await response + .clone() + .text() + .catch(() => '') + ); + return response; + }; + + const client = new Client({ name: 'legacy-client', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(baseUrl, { fetch: sniffingFetch })); + cleanups.push(() => client.close()); + + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + const result = await client.callTool({ name: 'echo', arguments: { text: 'legacy' } }); + expect(result.content).toEqual([{ type: 'text', text: 'legacy' }]); + + // The 2026 revision never appears in any response the legacy client received. + for (const body of responses) { + expect(body).not.toContain(MODERN); + } + }); + + it('client.discover() on a legacy-era connection is rejected locally with a typed error', async () => { + // Default (legacy-only) server; the connection negotiates a legacy + // version, on which server/discover does not exist — the request is + // rejected locally before it reaches the wire. (The typed discover() + // round-trip over HTTP completes once every modern request carries the + // per-request _meta envelope.) + const httpServer: HttpServer = createServer(); + const mcpServer = new McpServer({ name: 'legacy-only', version: '1.0.0' }, { capabilities: { tools: {} } }); + const serverTransport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await mcpServer.connect(serverTransport); + httpServer.on('request', (req, res) => void serverTransport.handleRequest(req, res)); + const baseUrl = await listenOnRandomPort(httpServer); + cleanups.push(async () => { + await mcpServer.close().catch(() => {}); + await serverTransport.close().catch(() => {}); + httpServer.close(); + }); + + const client = new Client({ name: 'legacy-client', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(baseUrl)); + cleanups.push(() => client.close()); + + await expect(client.discover()).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.MethodNotSupportedByProtocolVersion + ); + }); +}); diff --git a/test/integration/test/client/versionNegotiation.test.ts b/test/integration/test/client/versionNegotiation.test.ts new file mode 100644 index 0000000000..a5aaee0148 --- /dev/null +++ b/test/integration/test/client/versionNegotiation.test.ts @@ -0,0 +1,288 @@ +/** + * Wire-real version negotiation fixtures: the probe against REAL deployed-shape + * servers over real HTTP. + * + * First-contact wire shapes (both deployment flavors): + * - stateless servers answer the probe 400/-32000 with the byte-exact + * "Unsupported protocol version" literal (version header checked, no session), + * - stateful servers answer 400/-32000 session-required free-text (session is + * checked BEFORE version). + * + * Plus: structural fallback hygiene (the auto client's post-probe traffic is + * byte-identical to a plain legacy client's, zero 2026 headers), the typed + * connect errors for outage and HTTP timeout, and the stdio timeout fallback + * (a silent legacy stdio server is detected by the probe timing out and the + * client falls back to initialize on the same pipe). + */ +import { randomUUID } from 'node:crypto'; +import type { Server } from 'node:http'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { McpServer } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +/** A fetch wrapper recording every request our client puts on the wire (URL, headers, body) and the raw response (status, body). */ +function recordingFetch() { + const calls: Array<{ + method: string; + headers: Record; + body: string | undefined; + status: number; + responseBody: string; + }> = []; + const fetchFn: typeof fetch = async (input, init) => { + const headers: Record = {}; + for (const [key, value] of new Headers(init?.headers).entries()) { + headers[key.toLowerCase()] = value; + } + const response = await fetch(input, init); + const clone = response.clone(); + const responseBody = await clone.text().catch(() => ''); + calls.push({ + method: init?.method ?? 'GET', + headers, + body: typeof init?.body === 'string' ? init.body : undefined, + status: response.status, + responseBody + }); + return response; + }; + return { calls, fetchFn }; +} + +const NEGOTIATION_HEADERS = ['mcp-protocol-version', 'mcp-method', 'mcp-name'] as const; + +async function setupLegacyServer(stateful: boolean) { + const httpServer: Server = createServer(); + const mcpServer = new McpServer({ name: 'deployed-2025-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + const serverTransport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: stateful ? () => randomUUID() : undefined + }); + await mcpServer.connect(serverTransport); + httpServer.on('request', (req, res) => void serverTransport.handleRequest(req, res)); + const baseUrl = await listenOnRandomPort(httpServer); + return { httpServer, mcpServer, serverTransport, baseUrl }; +} + +describe('version negotiation against real legacy servers (wire-real first-contact shapes)', () => { + const cleanups: Array<() => Promise | void> = []; + afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!(); + }); + + async function startLegacy(stateful: boolean) { + const setup = await setupLegacyServer(stateful); + cleanups.push(async () => { + await setup.mcpServer.close().catch(() => {}); + await setup.serverTransport.close().catch(() => {}); + setup.httpServer.close(); + }); + return setup; + } + + it('stateless deployment: the probe meets the 400/-32000 "Unsupported protocol version" literal, then falls back byte-clean', async () => { + const { baseUrl } = await startLegacy(false); + const { calls, fetchFn } = recordingFetch(); + + const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + const transport = new StreamableHTTPClientTransport(baseUrl, { fetch: fetchFn }); + await client.connect(transport); + cleanups.push(() => client.close()); + + // First contact: the probe POST (body-derived 2026 headers). + const probe = calls[0]!; + expect(probe.headers['mcp-protocol-version']).toBe('2026-07-28'); + expect(probe.headers['mcp-method']).toBe('server/discover'); + // Wire-real shape #1 — the deployed-fleet literal (Q10-L1; consumed as a fixture only). + expect(probe.status).toBe(400); + const probeBody = JSON.parse(probe.responseBody) as { error: { code: number; message: string } }; + expect(probeBody.error.code).toBe(-32_000); + expect(probeBody.error.message).toContain('Bad Request: Unsupported protocol version: 2026-07-28'); + expect(probeBody.error.message).toContain('supported versions:'); + + // Conservative fallback on the same connection. + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + + // Fallback hygiene: ZERO 2026 headers on every post-probe request. + for (const call of calls.slice(1)) { + expect(call.headers['mcp-method']).toBeUndefined(); + expect(call.headers['mcp-name']).toBeUndefined(); + const version = call.headers['mcp-protocol-version']; + if (version !== undefined) { + expect(version < '2026').toBe(true); + } + expect(call.body ?? '').not.toContain('2026-07-28'); + } + + // The legacy era works end to end. + const result = await client.callTool({ name: 'echo', arguments: { text: 'hi' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hi' }]); + }); + + it('stateful deployment: the probe meets 400/-32000 session-required free-text (session checked before version), then falls back', async () => { + const { baseUrl } = await startLegacy(true); + const { calls, fetchFn } = recordingFetch(); + + const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + const transport = new StreamableHTTPClientTransport(baseUrl, { fetch: fetchFn }); + await client.connect(transport); + cleanups.push(() => client.close()); + + // Wire-real shape #2 — stateful servers reject pre-init non-initialize + // POSTs before ever looking at the version header. + const probe = calls[0]!; + expect(probe.status).toBe(400); + const probeBody = JSON.parse(probe.responseBody) as { error: { code: number; message: string } }; + expect(probeBody.error.code).toBe(-32_000); + expect(probeBody.error.message).toBe('Bad Request: Server not initialized'); + + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + const result = await client.callTool({ name: 'echo', arguments: { text: 'stateful' } }); + expect(result.content).toEqual([{ type: 'text', text: 'stateful' }]); + }); + + it('diff-asserted fallback ≡ this client’s own plain legacy connect under identical ClientOptions', async () => { + const { baseUrl } = await startLegacy(false); + + const auto = recordingFetch(); + const autoClient = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await autoClient.connect(new StreamableHTTPClientTransport(baseUrl, { fetch: auto.fetchFn })); + cleanups.push(() => autoClient.close()); + await autoClient.callTool({ name: 'echo', arguments: { text: 'x' } }); + + const plain = recordingFetch(); + const plainClient = new Client({ name: 'neg-client', version: '1.0.0' }); + await plainClient.connect(new StreamableHTTPClientTransport(baseUrl, { fetch: plain.fetchFn })); + cleanups.push(() => plainClient.close()); + await plainClient.callTool({ name: 'echo', arguments: { text: 'x' } }); + + // Drop the probe exchange; everything after it must be identical to the + // plain client: same POST bodies (including the initialize body version) + // and the same headers (no clearing artifacts, no extras). + const autoPosts = auto.calls.filter(c => c.method === 'POST').slice(1); + const plainPosts = plain.calls.filter(c => c.method === 'POST'); + expect(autoPosts.length).toBe(plainPosts.length); + for (const [i, plainPost] of plainPosts.entries()) { + expect(autoPosts[i]!.body).toBe(plainPost!.body); + expect(autoPosts[i]!.headers).toEqual(plainPost!.headers); + for (const header of NEGOTIATION_HEADERS) { + if (header === 'mcp-protocol-version') continue; // legacy value allowed post-initialize + expect(autoPosts[i]!.headers[header]).toBeUndefined(); + } + } + }); +}); + +describe('typed connect errors (Q12) over real sockets', () => { + it('network outage (nothing listening): typed connect error, never a legacy verdict', async () => { + // Reserve a port, then close it so nothing is listening. + const placeholder = createServer(); + const url = await listenOnRandomPort(placeholder); + await new Promise(resolve => placeholder.close(() => resolve())); + + const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + const transport = new StreamableHTTPClientTransport(url); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + }); + + it('probe timeout: typed timeout error, no initialize ever sent', async () => { + // A server that accepts the request and never responds. + const hang = createServer(() => { + /* never answer */ + }); + const url = await listenOnRandomPort(hang); + + const { calls, fetchFn } = recordingFetch(); + const client = new Client( + { name: 'neg-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 300 } } } + ); + const transport = new StreamableHTTPClientTransport(url, { fetch: fetchFn }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout + ); + + // Probe POSTs only — zero initialize POSTs. + const posts = calls.filter(c => c.method === 'POST'); + expect(posts.every(c => c.headers['mcp-method'] === 'server/discover')).toBe(true); + expect(posts.every(c => (c.body ?? '').includes('server/discover'))).toBe(true); + expect(calls.some(c => (c.body ?? '').includes('"initialize"'))).toBe(false); + + await new Promise(resolve => hang.close(() => resolve())); + await new Promise(resolve => setTimeout(resolve, 50)); + }, 15_000); +}); + +describe('stdio: silent legacy server (probe timeout fallback)', () => { + // The stdio transport's backward-compatibility rule: a probe that gets no + // response within a reasonable timeout indicates a legacy server — some + // legacy servers do not respond to unknown pre-initialize requests at all + // — and the client falls back to initialize on the same pipe. (On HTTP, + // by contrast, a timeout stays a typed connect error; see the test above.) + const SILENT_LEGACY_SERVER_SCRIPT = String.raw` + let buffer = ''; + process.stdin.on('data', chunk => { + buffer += chunk.toString(); + let index; + while ((index = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, index); + buffer = buffer.slice(index + 1); + if (line.trim() === '') continue; + let message; + try { + message = JSON.parse(line); + } catch { + continue; + } + // A legacy server that simply ignores unknown pre-initialize + // requests (server/discover gets NO reply at all) but answers + // the initialize handshake normally. + if (message.method === 'initialize' && message.id !== undefined) { + process.stdout.write( + JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: '2025-11-25', + capabilities: {}, + serverInfo: { name: 'silent-legacy-stdio-server', version: '1.0.0' } + } + }) + '\n' + ); + } + } + }); + `; + + it('auto mode: the probe times out, the client falls back to initialize on the same pipe and connects on the legacy era', async () => { + const transport = new StdioClientTransport({ + command: process.execPath, + args: ['-e', SILENT_LEGACY_SERVER_SCRIPT] + }); + const client = new Client( + { name: 'neg-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 500 } } } + ); + + try { + await client.connect(transport); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + expect(client.getServerVersion()?.name).toBe('silent-legacy-stdio-server'); + } finally { + await client.close(); + } + }, 15_000); +}); diff --git a/test/integration/test/server/createMcpHandler.test.ts b/test/integration/test/server/createMcpHandler.test.ts new file mode 100644 index 0000000000..61e78d13d6 --- /dev/null +++ b/test/integration/test/server/createMcpHandler.test.ts @@ -0,0 +1,165 @@ +/** + * createMcpHandler served over real HTTP, driven by real clients: the + * 2026-capable negotiation client for the modern path and a plain 2025 client + * for the legacy slot — the three slot states on one endpoint, all backed by + * one factory. + */ +import type { Server as HttpServer } from 'node:http'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { CallToolResult, CreateMcpHandlerOptions, McpHttpHandler, McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, legacyStatelessFallback, McpServer } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +const MODERN = '2026-07-28'; + +describe('createMcpHandler over HTTP (slot states end to end)', () => { + const cleanups: Array<() => Promise | void> = []; + afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!(); + }); + + // One factory for both legs: the era only shows up in the tool output so the + // tests can see which leg served the call. + const factory = (ctx: McpRequestContext) => { + const mcpServer = new McpServer( + { name: 'dual-era-endpoint', version: '1.0.0' }, + { capabilities: { tools: {} }, instructions: 'dual era endpoint' } + ); + mcpServer.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name} (${ctx.era})` }] + })); + return mcpServer; + }; + + async function startEndpoint(options?: CreateMcpHandlerOptions): Promise<{ baseUrl: URL; handler: McpHttpHandler }> { + const handler = createMcpHandler(factory, options); + const httpServer: HttpServer = createServer((req, res) => void handler.node(req, res)); + const baseUrl = await listenOnRandomPort(httpServer); + cleanups.push(async () => { + await handler.close(); + httpServer.close(); + }); + return { baseUrl, handler }; + } + + function modernEnvelope() { + return { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'integration-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; + } + + it('serves the modern era to an auto-negotiating client (strict endpoint, no legacy slot)', async () => { + const { baseUrl } = await startEndpoint(); + + const client = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(baseUrl)); + cleanups.push(() => client.close()); + + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + expect(client.getServerVersion()).toEqual({ name: 'dual-era-endpoint', version: '1.0.0' }); + expect(client.getInstructions()).toBe('dual era endpoint'); + + // A typed tools/call round trip; the per-request envelope is attached + // explicitly here (automatic envelope emission for every modern request + // is a client-side follow-up). + const result = (await client.request({ + method: 'tools/call', + params: { name: 'greet', arguments: { name: 'modern' }, _meta: modernEnvelope() } + })) as CallToolResult; + expect(result.content).toEqual([{ type: 'text', text: 'hello modern (modern)' }]); + }); + + it('rejects a plain 2025 client on the strict endpoint with the unsupported-protocol-version error', async () => { + const { baseUrl } = await startEndpoint(); + + const client = new Client({ name: 'legacy-client', version: '1.0.0' }); + await expect(client.connect(new StreamableHTTPClientTransport(baseUrl))).rejects.toThrow(/Unsupported protocol version|400/); + cleanups.push(() => client.close().catch(() => {})); + }); + + it("serves a plain 2025 client through the 'stateless' legacy slot while the modern path keeps working", async () => { + const { baseUrl } = await startEndpoint({ legacy: 'stateless' }); + + const legacyClient = new Client({ name: 'legacy-client', version: '1.0.0' }); + await legacyClient.connect(new StreamableHTTPClientTransport(baseUrl)); + cleanups.push(() => legacyClient.close()); + + expect(legacyClient.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + const legacyResult = await legacyClient.callTool({ name: 'greet', arguments: { name: 'old friend' } }); + expect(legacyResult.content).toEqual([{ type: 'text', text: 'hello old friend (legacy)' }]); + + const modernClient = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await modernClient.connect(new StreamableHTTPClientTransport(baseUrl)); + cleanups.push(() => modernClient.close()); + + expect(modernClient.getNegotiatedProtocolVersion()).toBe(MODERN); + const modernResult = (await modernClient.request({ + method: 'tools/call', + params: { name: 'greet', arguments: { name: 'new friend' }, _meta: modernEnvelope() } + })) as CallToolResult; + expect(modernResult.content).toEqual([{ type: 'text', text: 'hello new friend (modern)' }]); + }); + + it('serves a plain 2025 client through a bring-your-own legacy handler', async () => { + const { baseUrl } = await startEndpoint({ legacy: legacyStatelessFallback(factory) }); + + const client = new Client({ name: 'legacy-client', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(baseUrl)); + cleanups.push(() => client.close()); + + const result = await client.callTool({ name: 'greet', arguments: { name: 'byo' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hello byo (legacy)' }]); + }); + + it('pinning the modern revision works against the entry and never sends initialize', async () => { + const { baseUrl } = await startEndpoint({ legacy: 'stateless' }); + + const bodies: string[] = []; + const recordingFetch: typeof fetch = async (input, init) => { + if (typeof init?.body === 'string') bodies.push(init.body); + return fetch(input, init); + }; + + const client = new Client({ name: 'pin-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(new StreamableHTTPClientTransport(baseUrl, { fetch: recordingFetch })); + cleanups.push(() => client.close()); + + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + expect(bodies.some(body => body.includes('"initialize"'))).toBe(false); + expect(bodies[0]).toContain('server/discover'); + }); + + it('answers an envelope claiming an unsupported revision with the supported list over plain HTTP', async () => { + const { baseUrl } = await startEndpoint(); + + // A request whose envelope claims an unsupported revision is answered with + // the unsupported-protocol-version error over plain HTTP 400. + const response = await fetch(new URL('/mcp', baseUrl), { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'greet', + arguments: { name: 'x' }, + _meta: { ...modernEnvelope(), [PROTOCOL_VERSION_META_KEY]: '2030-01-01' } + } + }) + }); + expect(response.status).toBe(400); + const body = (await response.json()) as { id: unknown; error: { code: number; data: { supported: string[] } } }; + expect(body.error.code).toBe(-32_004); + expect(body.error.data.supported).toEqual([MODERN]); + // The rejection echoes the request id it answers (it could be read from the body). + expect(body.id).toBe(1); + }); +}); diff --git a/test/integration/test/server/dualEraStdio.test.ts b/test/integration/test/server/dualEraStdio.test.ts new file mode 100644 index 0000000000..bc0b63088f --- /dev/null +++ b/test/integration/test/server/dualEraStdio.test.ts @@ -0,0 +1,194 @@ +/** + * Real-pipe dual-era stdio coverage: the fixture server + * (`__fixtures__/dualEraStdioServer.ts`, `eraSupport: 'dual-era'`, unchanged + * `StdioServerTransport`) is spawned as a real child process and driven over + * its stdio pipe by + * + * - a plain 2025 client (the `initialize` vertical, served exactly as today), + * - the negotiating client in auto mode (the 2026-07-28 vertical: + * `server/discover` on the pipe, then list → call with the per-request + * envelope), and + * - the long-lived era-gate negative on one connection: a legacy-classified + * `server/discover` answers a plain −32601 with zero 2026 vocabulary, while + * the same connection keeps serving both eras. + * + * Stdio behavior has no conformance harness (upstream conformance issue #258); + * this SDK e2e suite is its referee. + */ +import path from 'node:path'; + +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import type { JSONRPCMessage } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY +} from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; + +const FIXTURES_DIR = path.resolve(__dirname, '../__fixtures__'); +const MODERN = '2026-07-28'; + +const FORBIDDEN_2026_VOCABULARY = ['2026', 'discover', 'envelope', 'modern', 'era', '_meta', 'io.modelcontextprotocol', 'resultType']; + +function spawnFixtureTransport(): StdioClientTransport { + return new StdioClientTransport({ + command: process.execPath, + args: ['--import', 'tsx', 'dualEraStdioServer.ts'], + cwd: FIXTURES_DIR + }); +} + +/** Records every message the server writes onto the pipe (without detaching the client). */ +function recordInbound(transport: StdioClientTransport): JSONRPCMessage[] { + const inbound: JSONRPCMessage[] = []; + const original = transport.onmessage; + transport.onmessage = (message, extra) => { + inbound.push(message); + original?.(message, extra); + }; + return inbound; +} + +/** Records every message the client writes onto the pipe. */ +function recordOutbound(transport: StdioClientTransport): JSONRPCMessage[] { + const outbound: JSONRPCMessage[] = []; + const originalSend = transport.send.bind(transport); + transport.send = async (message, options) => { + outbound.push(message); + return originalSend(message, options); + }; + return outbound; +} + +/** Sends a raw JSON-RPC request on the live pipe and resolves with the matching response. */ +async function rawRequest(transport: StdioClientTransport, inbound: JSONRPCMessage[], request: JSONRPCMessage): Promise { + const id = (request as { id: string | number }).id; + const seen = inbound.length; + await transport.send(request); + return vi.waitFor( + () => { + const match = inbound.slice(seen).find(message => (message as { id?: string | number }).id === id); + if (!match) throw new Error('no response yet'); + return match; + }, + { timeout: 5000 } + ); +} + +describe('dual-era stdio server over a real child-process pipe', () => { + vi.setConfig({ testTimeout: 30_000 }); + + it('legacy vertical: a plain 2025 client is served via initialize, and the era gate stays vocabulary-clean on the same connection', async () => { + const transport = spawnFixtureTransport(); + const client = new Client({ name: 'legacy-pipe-client', version: '1.0.0' }); + // Raw writes below produce responses the protocol layer does not track. + client.onerror = () => {}; + + try { + await client.connect(transport); + const inbound = recordInbound(transport); + + // The 2025 vertical, byte-shape checks included. + expect(client.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + const tools = await client.listTools(); + expect(tools.tools.map(tool => tool.name)).toEqual(['echo']); + const result = await client.callTool({ name: 'echo', arguments: { text: 'over the real pipe' } }); + expect(result.content).toEqual([{ type: 'text', text: 'over the real pipe' }]); + expect(JSON.stringify(inbound)).not.toContain('resultType'); + + // Era-gate negative on the SAME connection: a legacy-classified + // server/discover answers a plain −32601 with zero 2026 vocabulary. + const gate = await rawRequest(transport, inbound, { + jsonrpc: '2.0', + id: 'raw-gate-1', + method: 'server/discover', + params: {} + }); + const error = (gate as { error: { code: number; message: string; data?: unknown } }).error; + expect(error.code).toBe(-32_601); + expect(error.message).toBe('Method not found'); + expect(error.data).toBeUndefined(); + const serialized = JSON.stringify(error).toLowerCase(); + for (const term of FORBIDDEN_2026_VOCABULARY) { + expect(serialized).not.toContain(term.toLowerCase()); + } + } finally { + await client.close(); + } + }); + + it('modern vertical: the auto-negotiating client reaches 2026-07-28 via server/discover on the pipe and both eras serve on one connection', async () => { + const transport = spawnFixtureTransport(); + const outbound = recordOutbound(transport); + const client = new Client({ name: 'modern-pipe-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + client.onerror = () => {}; + + try { + await client.connect(transport); + const inbound = recordInbound(transport); + + // 2026 negotiated via discover on the pipe — no initialize was ever written. + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + expect(outbound.some(message => (message as { method?: string }).method === 'initialize')).toBe(false); + expect((outbound[0] as { method?: string }).method).toBe('server/discover'); + + // Modern vertical: list → call, every request carrying the per-request envelope. + // (Attaching it explicitly is the documented stop-gap until automatic + // per-request envelope emission lands client-side.) + const envelope = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'modern-pipe-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; + // The list leg is asserted at the wire level: the 2026 wire schema + // for cacheable list results requires the ttlMs/cacheScope stamps, + // whose server-side stamping ships with the result-stamping + // milestone — the client-side typed decode of tools/list on the + // modern era completes once that lands. + const modernList = await rawRequest(transport, inbound, { + jsonrpc: '2.0', + id: 'raw-modern-list', + method: 'tools/list', + params: { _meta: envelope } + }); + const modernListResult = (modernList as { result?: { tools?: Array<{ name: string }>; resultType?: string } }).result; + expect(modernListResult?.tools?.map(tool => tool.name)).toEqual(['echo']); + expect(modernListResult?.resultType).toBe('complete'); + + const result = await client.request({ + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'modern leg' }, _meta: envelope } + }); + expect(result.content).toEqual([{ type: 'text', text: 'modern leg' }]); + + // Both eras concurrently on ONE connection: a raw legacy (envelope-less) + // request on the same pipe is served on the 2025 era… + const legacyList = await rawRequest(transport, inbound, { + jsonrpc: '2.0', + id: 'raw-legacy-list', + method: 'tools/list', + params: {} + }); + const legacyResult = (legacyList as { result?: { tools?: Array<{ name: string }>; resultType?: string } }).result; + expect(legacyResult?.tools?.map(tool => tool.name)).toEqual(['echo']); + expect(legacyResult?.resultType).toBeUndefined(); + + // …while the era-gate negative holds on the same connection too. + const gate = await rawRequest(transport, inbound, { + jsonrpc: '2.0', + id: 'raw-gate-2', + method: 'subscriptions/listen', + params: {} + }); + const error = (gate as { error: { code: number; message: string; data?: unknown } }).error; + expect(error.code).toBe(-32_601); + expect(error.message).toBe('Method not found'); + expect(error.data).toBeUndefined(); + } finally { + await client.close(); + } + }); +}); diff --git a/test/integration/test/taskResumability.test.ts b/test/integration/test/transportResumability.test.ts similarity index 100% rename from test/integration/test/taskResumability.test.ts rename to test/integration/test/transportResumability.test.ts