diff --git a/.changeset/add-connect-prior.md b/.changeset/add-connect-prior.md new file mode 100644 index 0000000000..add9553e50 --- /dev/null +++ b/.changeset/add-connect-prior.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': minor +--- + +Add `connect(transport, { prior: DiscoverResult })` for zero-round-trip reconnect (the gateway / distributed-client pattern). Supplying a previously-obtained `DiscoverResult` skips the `server/discover` probe: on a 2026-era server `connect()` sends nothing on the wire and `callTool()` etc. work immediately. Pair with the new `client.getDiscoverResult()` (populated by the `'auto'`-mode probe, by `client.discover()`, and by `connect({ prior })` itself) — the value round-trips through `JSON.stringify`, so a gateway can probe once, persist the blob, and feed it to every worker. Only reuse a persisted `DiscoverResult` across clients that present the same authorization context as the client that obtained it. diff --git a/.changeset/add-request-state-codec.md b/.changeset/add-request-state-codec.md new file mode 100644 index 0000000000..8d93b0f9b5 --- /dev/null +++ b/.changeset/add-request-state-codec.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': minor +--- + +Add `createRequestStateCodec({ key, ttlSeconds?, bind? })`, an opt-in HMAC-SHA256 sealing helper for the multi-round-trip `requestState`: `mint` seals a JSON-serializable payload (with TTL and optional context binding) and `verify` drops directly into `ServerOptions.requestState.verify`. WebCrypto-based and runtime-neutral; verification is fail-closed and constant-time. The `ServerOptions.requestState.verify` hook's return type is widened to `unknown | Promise` (the seam already discarded the return value) so the codec's `verify` is directly assignable. 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/auth-dcr-hygiene.md b/.changeset/auth-dcr-hygiene.md new file mode 100644 index 0000000000..c6eb5e4140 --- /dev/null +++ b/.changeset/auth-dcr-hygiene.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/core': minor +--- + +Dynamic Client Registration hygiene for the 2026-07-28 authorization requirements (SEP-837, SEP-2207). New `resolveClientMetadata(provider)` reads `provider.clientMetadata` and applies the spec defaults — `application_type` derived from the redirect URIs (loopback or custom scheme → `'native'`, otherwise `'web'`), `grant_types: ['authorization_code', 'refresh_token']` when omitted — and `auth()` feeds the resolved document to DCR only (scope selection still reads the raw consumer-supplied `clientMetadata` so statically-registered/CIMD clients are not pushed into `offline_access` + `prompt=consent`); consumer-set values are never overwritten. DCR rejection now throws the new `RegistrationRejectedError` carrying the HTTP status, raw body, and submitted metadata — **breaking for direct `registerClient()` callers**: rejection no longer throws `OAuthError`, so update `instanceof` checks. `OAuthClientMetadata` gains a typed `application_type?: string` field (expected `'native'` / `'web'`; tolerant on parse). `OAuthErrorCode` adds `InvalidRedirectUri`. The token-exchange, refresh, and Cross-App Access (`requestJwtAuthorizationGrant` / `exchangeJwtAuthGrant`) paths now throw the new `InsecureTokenEndpointError` for a non-`https:` token endpoint (`localhost` / `127.0.0.1` / `::1` exempt), and `auth()` surfaces it on the refresh branch instead of silently re-authorizing. diff --git a/.changeset/auth-iss-server-and-overload.md b/.changeset/auth-iss-server-and-overload.md new file mode 100644 index 0000000000..af376f4c8f --- /dev/null +++ b/.changeset/auth-iss-server-and-overload.md @@ -0,0 +1,6 @@ +--- +"@modelcontextprotocol/client": minor +"@modelcontextprotocol/server-legacy": minor +--- + +SEP-2468 follow-up: `transport.finishAuth()` gains a `URLSearchParams` overload (preferred) that extracts `code`/`iss`, validates `iss` first, and on mismatch throws a sanitized `IssuerMismatchError` (no callback `error_description` text); callers remain responsible for `state`. **Behavior change for `@modelcontextprotocol/server-legacy`:** `mcpAuthRouter` now advertises `authorization_response_iss_parameter_supported` (default `true`; `ProxyOAuthServerProvider` reports `false`) and the bundled authorize handler appends `iss` (RFC 9207) to every `res.redirect(...)` your `OAuthServerProvider.authorize()` issues to the client's `redirect_uri`. If your provider redirects another way (`res.writeHead`, a separate consent-page response, or a standalone `authorizationHandler({provider})` without `issuerUrl`), append `params.issuer` as `iss` yourself or set `authorizationResponseIssParameterSupported: false` — otherwise RFC 9207-compliant clients (including this SDK) will reject the callback. diff --git a/.changeset/auth-iss-validation.md b/.changeset/auth-iss-validation.md new file mode 100644 index 0000000000..1135eab96a --- /dev/null +++ b/.changeset/auth-iss-validation.md @@ -0,0 +1,6 @@ +--- +"@modelcontextprotocol/core": minor +"@modelcontextprotocol/client": minor +--- + +Implement RFC 9207 / RFC 8414 §3.3 OAuth issuer validation (SEP-2468). `discoverAuthorizationServerMetadata()` now rejects metadata whose `issuer` does not match the discovery URL (opt out via `skipIssuerValidation` / `AuthOptions.skipIssuerMetadataValidation` — security-weakening). `auth()`, `exchangeAuthorization()`, `fetchToken()`, and `transport.finishAuth(code, iss?)` now validate the authorization-callback `iss` against the recorded issuer before redeeming the code; new `IssuerMismatchError` and `validateAuthorizationResponseIssuer()` are exported. diff --git a/.changeset/auth-sep-2352-credential-isolation.md b/.changeset/auth-sep-2352-credential-isolation.md new file mode 100644 index 0000000000..550d98c52a --- /dev/null +++ b/.changeset/auth-sep-2352-credential-isolation.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': minor +--- + +Per-authorization-server credential isolation (SEP-2352). `auth()` now stamps an `issuer` field onto every value it passes to `saveTokens()` / `saveClientInformation()` and threads `{ issuer }` to `tokens()` / `clientInformation()`; on read, a stored credential whose stamp names a different authorization server is treated as `undefined`, so a `client_id` / `refresh_token` issued by one AS is never sent to another. Providers that round-trip stored values verbatim are protected with no code change; multi-AS providers may key storage on `ctx.issuer`. New `AuthorizationServerMismatchError` (callback-leg gate). `OAuthClientProvider.saveAuthorizationServerUrl()` / `authorizationServerUrl()` are deprecated (still written, never read). `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider`, and `CrossAppAccessProvider` gain `expectedIssuer` and no longer define `saveClientInformation()`. diff --git a/.changeset/auth-surface-delta.md b/.changeset/auth-surface-delta.md new file mode 100644 index 0000000000..04600674b3 --- /dev/null +++ b/.changeset/auth-surface-delta.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/core': minor +--- + +Add the public surface for the 2026-07-28 authorization requirements. New `AuthOptions` type names the `auth()` options object and adds `iss` and `skipIssuerMetadataValidation` fields. `OAuthClientProvider.clientInformation()` / `.saveClientInformation()` / `.tokens()` / `.saveTokens()` accept an optional `OAuthClientInformationContext` carrying the authorization server's `issuer` so providers can key persisted credentials per authorization server. New `StoredOAuthTokens` / `StoredOAuthClientInformation` aliases add an `issuer` stamp field on top of the wire types (kept off the wire schemas so an authorization server cannot populate it) and become the parameter/return types of the credential methods. New `OAuthClientFlowError` base class in `authErrors.ts` for the flow-specific error classes that follow. All changes are additive — existing `OAuthClientProvider` implementations compile unchanged; the new fields are inert until the behavior changes that follow wire them up. 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-honor-cache-hints.md b/.changeset/client-honor-cache-hints.md new file mode 100644 index 0000000000..2a7659db63 --- /dev/null +++ b/.changeset/client-honor-cache-hints.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/client': minor +--- + +`Client` now **honours** the server-stamped SEP-2549 `ttlMs`/`cacheScope` cache hints on the cacheable verbs (`listTools()`, `listPrompts()`, `listResources()`, `listResourceTemplates()`, `readResource()`): a still-fresh held entry is served without a round trip. New `CacheableRequestOptions.cacheMode` (`'use'` — the default; `'refresh'` — always fetch and re-store; `'bypass'` — fetch without consulting or writing the cache) gives per-call control. The behaviour is opt-in by hint: a server that sends `ttlMs: 0` (the conservative default this SDK's server stamps) sees byte-identical behaviour — every call fetches. + +Entries are automatically scoped by connected-server identity (derived from `serverInfo` after connect, encoded collision-free via `JSON.stringify`); `ClientOptions.cachePartition` is the opaque per-principal slot for `'private'`-scoped entries — set it to your principal identifier (e.g. the auth subject) when one `responseCacheStore` backs several principals. With the default `''` every entry lives at the connected server's shared partition (the safe single-tenant posture). `ClientOptions.defaultCacheTtlMs` (default `0`) supplies the TTL when a result lacks one (e.g. a legacy-era response); the server-supplied `ttlMs` is clamped at 24 h (`MAX_CACHE_TTL_MS`). The list verbs always store the aggregate (so `callTool`'s mirroring/output-validation index keeps working at any TTL); `readResource` stores only when the resolved TTL is positive. `notifications/resources/updated` evicts the cached `resources/read` body for that URI. `ResponseCacheStore` gained `delete(key)`; `InMemoryResponseCacheStore` is now bounded (`{ maxEntries }`, default 512, oldest-first eviction). New exports: `CacheMode`, `CacheableRequestOptions`, `InMemoryResponseCacheStoreOptions`, `MAX_CACHE_TTL_MS`. diff --git a/.changeset/client-http-stream-close-cancel.md b/.changeset/client-http-stream-close-cancel.md new file mode 100644 index 0000000000..3af099e644 --- /dev/null +++ b/.changeset/client-http-stream-close-cancel.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +--- + +Client request cancellation on a 2026-07-28 Streamable HTTP connection now closes that request's SSE response stream — the spec cancellation signal — instead of POSTing `notifications/cancelled`. Cancellation on a 2025-era connection, and on stdio at any era, still sends `notifications/cancelled` as before. Adds the optional `Transport.hasPerRequestStream` capability flag (set on `StreamableHTTPClientTransport`) for the protocol layer to route the per-transport cancel path. 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/client-response-cache-substrate.md b/.changeset/client-response-cache-substrate.md new file mode 100644 index 0000000000..fc24016dc6 --- /dev/null +++ b/.changeset/client-response-cache-substrate.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/client': major +--- + +`Client.listTools()` / `listPrompts()` / `listResources()` / `listResourceTemplates()` now **auto-aggregate every page** when called without a `cursor` and return the complete result with `nextCursor: undefined` (matching the C#, Java, and mcp.d SDKs). Pass an explicit `{ cursor }` string to fetch a single page; the per-page path is unchanged. Existing manual pagination loops keep working — the first iteration returns everything and the loop exits — but can be deleted. The aggregated result is written to the new pluggable `ResponseCacheStore` (default: a fresh per-instance `InMemoryResponseCacheStore`); a `ClientResponseCache` collaborator owns the eviction-generation guard and the derived `tools/list` index that `callTool`'s output validation and SEP-2243 `Mcp-Param-*` mirroring read. New exports: `ResponseCacheStore`, `CacheKey`, `CacheEntry`, `CacheScope`, `MaybePromise`, `InMemoryResponseCacheStore`; new `ClientOptions.responseCacheStore` / `ClientOptions.listMaxPages` (caps the auto-aggregate walk at 64 pages by default; throws `SdkError` with `SdkErrorCode.ListPaginationExceeded` on overrun so a partial aggregate is never cached). The store interface is async-ready (`MaybePromise<…>`); the in-memory default stays synchronous. Entries are automatically scoped by the connected server's identity and (when set) the consumer-supplied `cachePartition`, so a shared store does not collide across servers or principals; evictions are likewise scoped to the connected server's partitions. + +**Behavior change (every era):** output-schema validator compilation is now lazy — validators are compiled on the first `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()`. `listTools()` no longer throws on an uncompilable `outputSchema` (every tool stays listed; the compile failure is captured per-tool); calling `callTool()` on the affected tool throws `ProtocolError(InvalidParams, "Tool 'X' has an invalid outputSchema: …")` before the request is sent — output-schema validation is never silently skipped. A pluggable `jsonSchemaValidator` provider therefore observes compilation at `callTool` time, not `listTools` time. The legacy-era `listTools()` path is unchanged at the wire level but is observably different at the validator-lifecycle level. 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..d481ac6ccf --- /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/support-2026-07-28.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/codemod-v1-to-v2-gaps.md b/.changeset/codemod-v1-to-v2-gaps.md new file mode 100644 index 0000000000..d712b55cf2 --- /dev/null +++ b/.changeset/codemod-v1-to-v2-gaps.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/codemod': patch +--- + +v1-to-v2: now wraps `outputSchema` raw shapes with `z.object()`; importMap covers `sdk/server/express.js`, `sdk/server/middleware/hostHeaderValidation.js`, and `sdk/client/auth-extensions.js`. The unreachable `expressMiddleware` transform is removed. diff --git a/.changeset/config.json b/.changeset/config.json index eb43bdc7fd..6821c8c0ce 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -8,10 +8,9 @@ "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [ - "@modelcontextprotocol/examples-client", + "@modelcontextprotocol/examples", "@modelcontextprotocol/examples-client-quickstart", - "@modelcontextprotocol/examples-server", "@modelcontextprotocol/examples-server-quickstart", - "@modelcontextprotocol/examples-shared" + "@mcp-examples/*" ] } diff --git a/.changeset/create-mcp-handler-legacy-revision.md b/.changeset/create-mcp-handler-legacy-revision.md new file mode 100644 index 0000000000..4212856377 --- /dev/null +++ b/.changeset/create-mcp-handler-legacy-revision.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/server': minor +--- + +Revise `createMcpHandler`'s legacy handling (a behavior change to the unreleased entry). The entry now serves 2025-era (non-envelope) traffic **by default** through per-request stateless serving from the same factory — `legacy: 'stateless'` is the default rather than an +opt-in — and the strict, modern-only posture is selected with the new `legacy: 'reject'` value (the earlier alpha's default). The handler-valued `legacy` option (bring-your-own legacy serving) is removed: existing legacy deployments (for example a sessionful streamable +HTTP wiring) keep serving 2025 traffic by routing in user land with the new `isLegacyRequest(request, parsedBody?)` export, which runs the entry's own classification step — it returns `true` only for requests with no per-request `_meta` envelope claim, while malformed or +incomplete modern claims are NOT legacy and must be routed to the modern handler, which answers them with the documented validation errors. The predicate classifies a clone, so the routed request body stays readable. `legacyStatelessFallback` remains exported as a +standalone fetch-shaped handler with the same stateless serving as the default. diff --git a/.changeset/create-mcp-handler.md b/.changeset/create-mcp-handler.md new file mode 100644 index 0000000000..83906108be --- /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 selected with the `legacy` option (`'stateless'` — the default — for per-request stateless serving via the existing streamable HTTP transport, `'reject'` for a modern-only strict endpoint +that answers 2025-era requests with the unsupported-protocol-version error naming its supported revisions). The handler is a web-standard `{ fetch, close, notify, bus }` object: `fetch(request, { authInfo?, parsedBody? })` is the only request face (Node frameworks wrap it with +`toNodeHandler(handler)` from `@modelcontextprotocol/node`), and `close()` tears down in-flight modern exchanges. Also exported: `legacyStatelessFallback` (the same stateless legacy serving as a standalone fetch-shaped handler), 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/envelope-auto-emission.md b/.changeset/envelope-auto-emission.md new file mode 100644 index 0000000000..9ae614a84f --- /dev/null +++ b/.changeset/envelope-auto-emission.md @@ -0,0 +1,11 @@ +--- +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/core': minor +--- + +Per-request `_meta` envelope auto-emission on modern-era connections: once a client negotiates a 2026-07-28+ protocol revision (via `versionNegotiation: { mode: 'auto' }` or `{ pin }`), it automatically attaches the reserved protocol-version / client-info / client-capabilities +`_meta` keys to every outgoing request and notification — you no longer set the envelope by hand. User-supplied `_meta` keys take precedence over the auto-attached ones; the auto-attached client-capabilities reflect what the client actually registered. Legacy-era connections +(the default, and the `'auto'`-mode fallback) never gain these keys, so 2025-era outbound traffic is byte-identical to before. + +Adds `Client.getProtocolEra()` (`'legacy' | 'modern' | undefined`), the `ProtocolEra` type, `Client.setVersionNegotiation()` for configuring negotiation pre-connect on an already-constructed instance, and the `probe.maxRetries` knob (default `0`) which governs probe-timeout +re-sends only — the spec-mandated `-32022` corrective continuation is never counted against it. The `versionNegotiation` default remains `'legacy'`: absent (or `mode: 'legacy'`), `connect()` runs the plain 2025 sequence, byte-identical to a v1.x client. 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-session-status-codes.md b/.changeset/fix-session-status-codes.md deleted file mode 100644 index ff2a264bfc..0000000000 --- a/.changeset/fix-session-status-codes.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/examples-server': patch ---- - -Example servers now return HTTP 404 (not 400) when a request includes an unknown session ID, so clients can correctly detect they need to start a new session. Requests missing a session ID entirely still return 400. 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/fix-unknown-tool-protocol-error.md b/.changeset/fix-unknown-tool-protocol-error.md index 086158b4b6..9e6ce81ffd 100644 --- a/.changeset/fix-unknown-tool-protocol-error.md +++ b/.changeset/fix-unknown-tool-protocol-error.md @@ -9,7 +9,6 @@ Fix error handling for unknown tools and resources per MCP spec. code `-32602` (InvalidParams) instead of `CallToolResult` with `isError: true`. Callers who checked `result.isError` for unknown tools should catch rejected promises instead. -**Resources:** Unknown resource reads now return error code `-32002` (ResourceNotFound) -instead of `-32602` (InvalidParams). - -Added `ProtocolErrorCode.ResourceNotFound`. +**Resources:** Added `ProtocolErrorCode.ResourceNotFound` (`-32002`) as receive-tolerated +vocabulary. The wire code emitted for an unknown `resources/read` URI is `-32602` +(Invalid Params) — see the `resource-not-found-32602` changeset. diff --git a/.changeset/handler-drop-node-face.md b/.changeset/handler-drop-node-face.md new file mode 100644 index 0000000000..6442e14148 --- /dev/null +++ b/.changeset/handler-drop-node-face.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/server': major +'@modelcontextprotocol/node': minor +--- + +`createMcpHandler` now returns a web-standards-only `{ fetch, close, notify, bus }` handler — the shape Workers/Bun/Deno expect from `export default`. The duck-typed `.node(req, res, parsedBody?)` face is removed; Node frameworks (Express, Fastify, plain `node:http`) wrap the +handler once with the new `toNodeHandler(handler, { onerror? })` exported from `@modelcontextprotocol/node`, which converts the Node request to a web-standard `Request`, calls `handler.fetch`, and writes the `Response` back honoring write backpressure. The optional `onerror` +receives the adapter-level error fallback (request conversion / `handler.fetch` throw) before the `500` response is written, restoring observability parity with the removed `.node` face. `NodeIncomingMessageLike` and `NodeServerResponseLike` move from +`@modelcontextprotocol/server` to `@modelcontextprotocol/node`. diff --git a/.changeset/hide-wire-only-members.md b/.changeset/hide-wire-only-members.md new file mode 100644 index 0000000000..a247993107 --- /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/support-2026-07-28.md "Wire-only members hidden from public types". diff --git a/.changeset/missing-client-capability-error.md b/.changeset/missing-client-capability-error.md new file mode 100644 index 0000000000..842e44073b --- /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 `-32021` 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/mrtr-client-engine.md b/.changeset/mrtr-client-engine.md new file mode 100644 index 0000000000..451f717e10 --- /dev/null +++ b/.changeset/mrtr-client-engine.md @@ -0,0 +1,12 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +--- + +Add the client side of multi round-trip requests (protocol revision 2026-07-28, SEP-2322). The neutral `InputRequest`/`InputResponse`/`InputRequests`/`InputResponses`/`InputRequiredResult` types and the `isInputRequiredResult()` guard ship as the neutral surface (the +`inputRequired()` builder family and the `acceptedContent()` reader are exported by the server package as part of the server-side change); the 2026-07-28 wire codec models the in-band vocabulary (embedded requests and bare responses) and the retry-channel request fields. On the +client, an `input_required` answer to `tools/call`, `prompts/get`, or `resources/read` on a 2026-07-28 connection is now fulfilled automatically by default: the embedded requests are dispatched to the client's already-registered elicitation/sampling/roots handlers, and the +original call is retried with the collected `inputResponses`, a byte-exact echo of the opaque `requestState`, and a fresh request id, up to `inputRequired.maxRounds` rounds (default 10; exhaustion raises a typed `InputRequiredRoundsExceeded` error carrying the last result). +`client.callTool()` and its siblings keep returning their plain result types. `ClientOptions.inputRequired` (`autoFulfill`, `maxRounds`) configures the driver; manual mode is `autoFulfill: false` plus the per-call `allowInputRequired: true` request option and the +`withInputRequired()` schema wrapper. Retried requests surface their `inputResponses` to server handlers as bare response objects — entries in a wrapped `{method, result}` shape are dropped and reported via `ctx.mcpReq.droppedInputResponseKeys`. 2025-era behavior is unchanged: +the legacy wire has no `input_required` vocabulary and the legacy server-to-client request flow is untouched. diff --git a/.changeset/mrtr-server-seam.md b/.changeset/mrtr-server-seam.md new file mode 100644 index 0000000000..51f7fa06b5 --- /dev/null +++ b/.changeset/mrtr-server-seam.md @@ -0,0 +1,12 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/server': minor +--- + +Add the server side of multi round-trip requests (protocol revision 2026-07-28, SEP-2322). Handlers for `tools/call`, `prompts/get`, and `resources/read` can return the value built by `inputRequired()` (exported from the server package together with `acceptedContent()`) +to request additional client input in-band; the structured-content requirement and the tools/call result-schema validation are skipped for that return, the encode seam emits it as `resultType: 'input_required'`, and the handler reads the responses on re-entry from +`ctx.mcpReq.inputResponses` (with non-bare entries reported via `ctx.mcpReq.droppedInputResponseKeys`). The seam re-checks the at-least-one rule for hand-built results, checks every embedded request against the capabilities the client declared on that request's envelope +(answering the typed `-32021` error on violation), and fails loudly — never emitting a mis-typed result — when an input-required value is returned from any other method or toward a 2025-era request. A `UrlElicitationRequiredError` escaping a handler on a 2026-era request +fails as an internal error with a clear steer to `inputRequired.elicitUrl(...)`, so the `-32042` error never reaches the 2026-07-28 wire; 2025-era serving keeps today's `-32042` behavior +exactly. The typed local error raised when push-style server-to-client request APIs are used while serving a 2026-era request now steers to `inputRequired(...)`. Tool, prompt, and resource callback types accept the new return alongside their existing result types; 2025-era +wire behavior is unchanged. An optional `ServerOptions.requestState.verify` hook lets a server integrity-check the echoed `requestState` before the handler runs — a throw answers the wire-level `-32602` Invalid Params error with `data.reason: 'invalid_request_state'`; the SDK provides no default verification. 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..a00bd2f4a2 --- /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 `-32020` (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 (`-32022` with the supported-versions list), the 2025-era serving paths, and the client-side probe handling are unchanged. diff --git a/.changeset/pre.json b/.changeset/pre.json index c4c3cf31a8..0fa4e3b738 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -5,11 +5,9 @@ "@modelcontextprotocol/eslint-config": "2.0.0", "@modelcontextprotocol/tsconfig": "2.0.0", "@modelcontextprotocol/vitest-config": "2.0.0", - "@modelcontextprotocol/examples-client": "2.0.0-alpha.0", + "@modelcontextprotocol/examples": "2.0.0-alpha.0", "@modelcontextprotocol/examples-client-quickstart": "2.0.0-alpha.0", - "@modelcontextprotocol/examples-server": "2.0.0-alpha.0", "@modelcontextprotocol/examples-server-quickstart": "2.0.0-alpha.0", - "@modelcontextprotocol/examples-shared": "2.0.0-alpha.0", "@modelcontextprotocol/client": "2.0.0-alpha.0", "@modelcontextprotocol/core": "2.0.0-alpha.0", "@modelcontextprotocol/express": "2.0.0-alpha.0", diff --git a/.changeset/protocol-pre-aborted-signal-wrap.md b/.changeset/protocol-pre-aborted-signal-wrap.md new file mode 100644 index 0000000000..a0a95758c8 --- /dev/null +++ b/.changeset/protocol-pre-aborted-signal-wrap.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +`Protocol.request()` now rejects with `SdkError(RequestTimeout, reason)` when called with an already-aborted signal, matching in-flight aborts. Previously the raw `signal.reason` was thrown. diff --git a/.changeset/resource-not-found-32602.md b/.changeset/resource-not-found-32602.md new file mode 100644 index 0000000000..4909224219 --- /dev/null +++ b/.changeset/resource-not-found-32602.md @@ -0,0 +1,24 @@ +--- +"@modelcontextprotocol/core": minor +"@modelcontextprotocol/server": major +"@modelcontextprotocol/client": minor +--- + +`resources/read` for an unknown URI now answers with JSON-RPC error code `-32602` +(Invalid Params) on every protocol revision, with `error.data.uri` echoing the +requested URI. The 2026-07-28 specification requires `-32602`; the v1.x SDK already +emitted `-32602` on earlier revisions, so v1.x peers see no change. + +This supersedes an interim `-32002` emission that shipped in earlier v2 alphas. The +era-aware encode seam (`WireCodec.encodeErrorCode`) maps any handler-thrown `-32002` +to `-32602` on the wire; note that a `-32002` thrown without `data.uri` is emitted as +a bare `-32602` and is no longer recognizable as resource-not-found — throw +`ResourceNotFoundError` (or include `data: { uri }`) to preserve the classification. + +`ProtocolErrorCode.ResourceNotFound` (`-32002`) remains importable as receive-tolerated +vocabulary; clients should accept both `-32602` and `-32002` from peers (the +specification's backwards-compatibility clause). The new typed `ResourceNotFoundError` +class carries `data.uri`, and `ProtocolError.fromError` reconstructs it from a `-32602` +only when `error.data` is exactly `{ uri: string }` (and nothing else), and from a +legacy `-32002` whenever `data.uri` is a string; a bare `-32002` without `data.uri` +stays a generic `ProtocolError`. diff --git a/.changeset/sep-2106-dialect-posture.md b/.changeset/sep-2106-dialect-posture.md new file mode 100644 index 0000000000..cf38126120 --- /dev/null +++ b/.changeset/sep-2106-dialect-posture.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': major +'@modelcontextprotocol/server': major +--- + +SEP-1613 / SEP-2106 (JSON Schema 2020-12 posture): the Node default JSON Schema validator is now `Ajv2020` (true draft 2020-12) instead of the draft-07 `Ajv` class — `$defs`/`prefixItems`/`unevaluatedProperties`/`dependentRequired` are now enforced where they were previously silently ignored; to opt back, construct the draft-07 instance with the v1 defaults — `const ajv = new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }); addFormats(ajv);` — and pass `new AjvJsonSchemaValidator(ajv)`. Schemas declaring a `$schema` other than 2020-12 are rejected with a clear error rather than mis-validating. `outputSchema` may now have a non-object root and `CallToolResult.structuredContent` is widened to `unknown` (a deliberate source-level break for typed consumers — see the migration guide for the narrowing pattern). Toward 2025-era clients McpServer wraps a non-object `outputSchema` (and the matching `structuredContent`) in a `{result: …}` envelope so the tool stays callable, with same-document `$ref`/`$dynamicRef` pointers rewritten to keep resolving — low-level `Server` users (those bypassing `McpServer` and registering a `tools/call` handler directly) get the same wrap by routing the result through the new `Server.projectCallToolResult(result, advertisedOutputSchema)`. Independently, on every era (the SEP's MUST applies regardless of client version), McpServer auto-appends a `TextContent` JSON serialisation when a handler returns non-object `structuredContent` without its own text block. The `structuredContent` presence check is `!== undefined` (not falsy) on both sides. Thanks @mattzcarey (#2249). diff --git a/.changeset/sep-2243-mcp-param-client.md b/.changeset/sep-2243-mcp-param-client.md new file mode 100644 index 0000000000..bdefc019a4 --- /dev/null +++ b/.changeset/sep-2243-mcp-param-client.md @@ -0,0 +1,10 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +--- + +SEP-2243 `Mcp-Param-*` client-side mirroring (protocol revision 2026-07-28). On a 2026-07-28 connection over Streamable HTTP, `Client.callTool()` now mirrors tool arguments designated with `x-mcp-header` in the tool's `inputSchema` into `Mcp-Param-{Name}` HTTP headers (with the spec's `=?base64?…?=` sentinel encoding for values that are not safe plain-ASCII field values), and on a non-stdio modern connection `Client.listTools()` (and the client's internal `tools/list` cache) exclude tool definitions whose `x-mcp-header` declarations violate the spec's constraints, logging a warning naming the tool and the reason. The legacy-era `callTool` and `listTools` paths are unchanged at the wire level. Browser environments skip mirroring (dynamically named headers cannot be statically allow-listed for credentialed CORS); a conforming SEP-2243 server will reject a `tools/call` whose body carries a non-null value for an `x-mcp-header` parameter when the matching header is absent, so calling such a tool with that argument from a browser is a known limitation. New `CallToolRequestOptions.toolDefinition` lets callers supply the tool definition directly so mirroring and output-schema validation can run without a prior `tools/list`. `TransportSendOptions.headers` is added (additive, optional) for per-request HTTP headers; the Streamable HTTP transport skips reserved standard/auth header names (`authorization`, `mcp-protocol-version`, `mcp-method`, `mcp-name`, `mcp-session-id`, `content-type`); transports that share a single channel (stdio, in-memory) ignore it. + +The Streamable HTTP transport now emits the `Mcp-Name` standard header on every modern-enveloped request (`params.name` for `tools/call`/`prompts/get`, `params.uri` for `resources/read`), sentinel-encoded. + +**Behavior change (modern era only):** on a modern-enveloped request the Streamable HTTP transport now surfaces an HTTP `400` whose body is a well-formed JSON-RPC error response addressed to the pending request id in-band as a `ProtocolError` (instead of `SdkHttpError`), so the `HEADER_MISMATCH` recovery retry can fire. Legacy-era exchanges are unchanged. diff --git a/.changeset/sep-2243-mcp-param-server.md b/.changeset/sep-2243-mcp-param-server.md new file mode 100644 index 0000000000..fc0316cfb7 --- /dev/null +++ b/.changeset/sep-2243-mcp-param-server.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': minor +--- + +SEP-2243 `Mcp-Param-*` server-side validation (protocol revision 2026-07-28). On the modern (2026-07-28) serving path, `createMcpHandler` now validates `Mcp-Param-{Name}` headers against the named tool's `x-mcp-header` declarations and the body `arguments` before dispatch: a missing header for a present body value, a header that decodes to a different value than the body, or an invalid `=?base64?…?=` sentinel is rejected with `400 Bad Request` and JSON-RPC `-32020` (`HeaderMismatch`) — the same shape the existing standard-header cross-checks emit. A `null`/absent body value passes regardless of any header (the spec's "server MUST NOT expect the header" rows). `McpServer.registerTool` now warns at registration time when an `x-mcp-header` declaration violates the spec's constraints. The 2025-era serving paths and the low-level `Server` factory shape are unchanged. diff --git a/.changeset/sep-2243-std-header-server.md b/.changeset/sep-2243-std-header-server.md new file mode 100644 index 0000000000..584492034a --- /dev/null +++ b/.changeset/sep-2243-std-header-server.md @@ -0,0 +1,10 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/server': minor +--- + +SEP-2243 standard-header server-side validation (protocol revision 2026-07-28). On the modern (2026-07-28) serving path, `createMcpHandler` now enforces the required `Mcp-Method` and `Mcp-Name` standard request headers in addition to the existing `MCP-Protocol-Version` and `Mcp-Method` cross-checks: a modern request without an `Mcp-Method` header, a `tools/call` / `prompts/get` / `resources/read` request without an `Mcp-Name` header, an `Mcp-Name` header carrying an invalid `=?base64?…?=` sentinel, and an `Mcp-Name` header whose (decoded) value disagrees with the body's `params.name` / `params.uri` are all rejected with `400 Bad Request` and JSON-RPC `-32020` (`HeaderMismatch`). The 2025-era serving paths are unchanged. + +New public surface: + +- `@modelcontextprotocol/core`: `validateStandardRequestHeaders` (function), `MCP_NAME_HEADER_SOURCE` (const), the `mcpNameHeader` field on `InboundHttpRequest`, and the `'standard-header-validation'` member of `InboundValidationRung` (with `client-capabilities` / `param-header-validation` renumbered). diff --git a/.changeset/sep-2350-scope-step-up.md b/.changeset/sep-2350-scope-step-up.md new file mode 100644 index 0000000000..e2155b53e4 --- /dev/null +++ b/.changeset/sep-2350-scope-step-up.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': minor +--- + +SEP-2350 scope step-up: on `403 insufficient_scope`, `StreamableHTTPClientTransport` now re-authorizes with the **union** of the previously-requested and challenged scopes (`computeScopeUnion`), bypassing the refresh-token branch when the union is a strict superset of the current token's granted scope (`isStrictScopeSuperset`, `AuthOptions.forceReauthorization`). New `onInsufficientScope: 'reauthorize' | 'throw'` (default `'reauthorize'`) and `maxStepUpRetries` (default 1) on `StreamableHTTPClientTransportOptions`; `'throw'` raises the new `InsufficientScopeError`. The GET listen-stream open path now applies the same step-up handling. The previous verbatim-header retry guard is replaced by the bounded per-send counter. diff --git a/.changeset/sep-2577-deprecate-type-stacks.md b/.changeset/sep-2577-deprecate-type-stacks.md new file mode 100644 index 0000000000..0fbe31bf9e --- /dev/null +++ b/.changeset/sep-2577-deprecate-type-stacks.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/client': patch +--- + +Complete the SEP-2577 `@deprecated` sweep on the public type surface (SEP-2596 Tier-1 obligation). Marks the full Logging type stack (`LoggingLevel`, `SetLevelRequest`, `LoggingMessageNotification` and params), the full Sampling type stack (`CreateMessageRequest`/`Result`, `SamplingMessage`, `ModelPreferences`/`ModelHint`, `ToolChoice`, `ToolUseContent`/`ToolResultContent`, and the `includeContext` enum values), the full Roots type stack (`Root`, `ListRootsRequest`/`Result`, `RootsListChangedNotification`), and `registerClient` (Dynamic Client Registration; prefer Client ID Metadata Documents per SEP-991). Mirrors the markers already present on the per-revision reference types. JSDoc only — wire behavior is unchanged; everything remains fully functional during the deprecation window (at least twelve months). diff --git a/.changeset/server-ctx-log-request-related.md b/.changeset/server-ctx-log-request-related.md new file mode 100644 index 0000000000..8bc91794ea --- /dev/null +++ b/.changeset/server-ctx-log-request-related.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/server': patch +--- + +`ctx.mcpReq.log()` now emits its `notifications/message` notification request-related (like progress and `ctx.mcpReq.notify`), so handler-emitted log messages are delivered when the server is hosted per request via `createMcpHandler` instead of being silently dropped. On a 2026-07-28 request the level filter consults the per-request `_meta` `io.modelcontextprotocol/logLevel` key (the modern equivalent of `logging/setLevel`); per the spec, an absent key means no `notifications/message` is sent for that request. + +**2025-era delivery-channel change (spec-conformance correction).** On a 2025-era sessionful Streamable HTTP transport, handler-emitted log messages now ride the per-request POST response stream instead of the standalone GET stream. This is a correction to the 2025-11-25 specification: `docs/specification/2025-11-25/basic/transports.mdx` §"Sending Messages to the Server" item 6 says JSON-RPC messages on the POST response stream SHOULD relate to the originating client request, and §"Listening for Messages from the Server" item 4 says messages on the GET stream SHOULD be unrelated to any concurrently-running client request — so a log emitted from a handler context belongs on the POST stream. Clients reading handler logs off the standalone GET stream will now see them on the per-request POST stream instead. The eventStore-resumable case (a log emitted after `closeSSE()` while the client has not yet reconnected) is handled by the store-first persistence behavior in `WebStandardStreamableHTTPServerTransport.send()`. The session-scoped `Server.sendLoggingMessage()` API is unchanged. diff --git a/.changeset/server-serve-stdio.md b/.changeset/server-serve-stdio.md new file mode 100644 index 0000000000..d7331aaaeb --- /dev/null +++ b/.changeset/server-serve-stdio.md @@ -0,0 +1,11 @@ +--- +'@modelcontextprotocol/server': minor +--- + +Add `serveStdio(factory, options?)` (exported from `@modelcontextprotocol/server/stdio`), the connection-pinned stdio entry point for serving the 2026-07-28 draft revision on long-lived connections. The entry owns the transport and the era decision: the client's opening +exchange selects the era (a 2025 `initialize` handshake, 2026-07-28 per-request `_meta` envelope traffic, or a `server/discover` probe followed by either), and ONE instance from the factory is pinned to the connection and serves only that era — mirroring how +`createMcpHandler` classifies each HTTP request before constructing an instance. 2025-era openings are served by default; `legacy: 'reject'` answers them with the unsupported-protocol-version error naming the supported modern revisions instead. A `transport` option +accepts a bring-your-own `StdioServerTransport` (for example over a Unix domain socket); `onerror` reports out-of-band errors; the returned handle's `close()` tears the connection down. + +Removed: `ServerOptions.eraSupport` (introduced in an earlier 2.0 alpha, never in a stable release). A hand-constructed `Server`/`McpServer` serves only the 2025-era protocol it was written for; serving the 2026-07-28 revision always goes through a serving entry. Migrate +`new McpServer(info, { eraSupport: 'dual-era' })` + `connect(new StdioServerTransport())` to `serveStdio(() => new McpServer(info))`, and `eraSupport: 'modern'` to `serveStdio(factory, { legacy: 'reject' })`. diff --git a/.changeset/server-streamablehttp-store-first.md b/.changeset/server-streamablehttp-store-first.md new file mode 100644 index 0000000000..cae12269c8 --- /dev/null +++ b/.changeset/server-streamablehttp-store-first.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +`WebStandardStreamableHTTPServerTransport`: request-related events (progress, `ctx.mcpReq.notify`, handler-emitted log) and the final response are now persisted to the configured `eventStore` whenever the request is in flight, regardless of whether a live SSE writer currently exists — mirroring the standalone-SSE path's store-first semantics. This fixes the `closeSSE()` poll-and-replay drop (events emitted after `closeSSE()` were previously silently lost) and aligns with the 2025-11-25 specification ("disconnection SHOULD NOT be interpreted as the client cancelling its request"). When an `eventStore` is configured, a final response sent while no per-request stream is connected is stored for replay and returns cleanly instead of throwing "No connection established"; a `Last-Event-ID` reconnect after the request has been retired replays the stored response and then closes the resumed stream. When no `eventStore` is configured, the same condition is surfaced via `onerror` (the response is undeliverable) and the request id is retired. diff --git a/.changeset/spec-2907-error-code-renumber.md b/.changeset/spec-2907-error-code-renumber.md new file mode 100644 index 0000000000..aeed3f11e2 --- /dev/null +++ b/.changeset/spec-2907-error-code-renumber.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +Align the 2026-07-28 protocol error codes to the spec renumber: `HeaderMismatch` is now `-32020` (was `-32001`), `MissingRequiredClientCapability` is now `-32021` (was `-32003`), and `UnsupportedProtocolVersion` is now `-32022` (was `-32004`). These codes are part of the draft 2026-07-28 protocol revision only and have never appeared on a 2025-era wire — the 2025 serving paths and the SDK-conventional `-32001` (`Session not found`) on the stateful Streamable HTTP transport are unchanged. `ProtocolErrorCode.MissingRequiredClientCapability`, `ProtocolErrorCode.UnsupportedProtocolVersion`, the `HEADER_MISMATCH_ERROR_CODE` constant, and the `HEADER_MISMATCH` / `MISSING_REQUIRED_CLIENT_CAPABILITY` / `UNSUPPORTED_PROTOCOL_VERSION` spec-type constants now carry the renumbered values; the `UnsupportedProtocolVersionError` and `MissingRequiredClientCapabilityError` classes (and `ProtocolError.fromError` recognition) follow. The client probe classifier recognizes `-32022` for the corrective continuation and the SEP-2243 one-refresh-on-miss retry triggers on `-32020`. diff --git a/.changeset/spec-anchor-repin-2fb207da.md b/.changeset/spec-anchor-repin-2fb207da.md new file mode 100644 index 0000000000..b021ff4d77 --- /dev/null +++ b/.changeset/spec-anchor-repin-2fb207da.md @@ -0,0 +1,13 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +Re-pin the 2026-07-28 draft references (spec reference types, vendored schema.json twins, example corpus) to the latest spec commit and align the 2026-era wire surface with it. Deliberate 2026-era wire behavior changes (the released 2025-11-25 surface is untouched): + +- `notifications/elicitation/complete` is no longer part of the 2026-07-28 wire registry (the draft removed the notification together with `elicitationId` on URL-mode elicitation). On connections negotiated at 2026-07-28, sending it — including via `Server.createElicitationCompletionNotifier()` — now fails locally with `SdkErrorCode.MethodNotSupportedByProtocolVersion`, and inbound copies are dropped as unknown notifications. Both remain fully supported on 2025-11-25. +- `notifications/cancelled` on 2026-era connections now parses with a revision-exact schema that requires `requestId` (the draft made it required); the notification `_meta` shape types the `io.modelcontextprotocol/subscriptionId` key. 2025-era parsing is unchanged. +- The error code `-32001` emitted for HTTP header/body mismatches is now defined by the draft schema (`HEADER_MISMATCH`); the emitted behavior is unchanged (documentation only). + +No public API surface changes; the regenerated reference artifacts are internal/test-only. 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-reference-types-2026-07-28.md b/.changeset/spec-reference-types-2026-07-28.md index df0101e05f..36f0a1f677 100644 --- a/.changeset/spec-reference-types-2026-07-28.md +++ b/.changeset/spec-reference-types-2026-07-28.md @@ -3,4 +3,4 @@ '@modelcontextprotocol/codemod': patch --- -Add per-revision spec reference types (2025-11-25 and 2026-07-28) with split comparison tests, and the 2026-07-28 wire contract surface: request-meta key constants, `RequestMetaEnvelopeSchema`, `server/discover` shapes, the typed `-32004` error, the `-32003` code constant, and a `resultType` passthrough on the base result. Types and constants only — no behavior changes. +Add per-revision spec reference types (2025-11-25 and 2026-07-28) with split comparison tests, and the 2026-07-28 wire contract surface: request-meta key constants, `RequestMetaEnvelopeSchema`, `server/discover` shapes, the typed `-32022` error, the `-32021` code constant, and a `resultType` passthrough on the base result. Types and constants only — no behavior changes. 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/subscriptions-listen-client.md b/.changeset/subscriptions-listen-client.md new file mode 100644 index 0000000000..3cc5c10757 --- /dev/null +++ b/.changeset/subscriptions-listen-client.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +--- + +`Client.listen(filter)` opens a `subscriptions/listen` stream on a 2026-07-28-era connection, resolving once the server's acknowledged notification arrives with an `McpSubscription { honoredFilter, close(), closed }`. `closed` is a `Promise<'local' | 'remote'>` that resolves exactly once when the subscription terminates (`'local'` = you called `close()`; `'remote'` = the server cancelled, the stream ended, or the transport dropped — re-listen if you still want events) and never rejects. Change notifications delivered on the stream dispatch to the existing `setNotificationHandler` registrations — the same handlers the 2025-era unsolicited notifications fire on a legacy connection — so `listen()` is era-transparent for consumers that already register those. `close()` aborts the listen request's stream (where the transport supports it) and sends `notifications/cancelled` referencing the listen id — both, on every transport; no automatic re-listen. On a 2025-era connection `listen()` throws a typed `MethodNotSupportedByProtocolVersion` steering to `resources/subscribe` and `ClientOptions.listChanged`. `ClientOptions.listChanged` now auto-opens a listen stream on a modern connection — the filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities; auto-open is skipped (`client.autoOpenedSubscription` stays `undefined`) when that intersection is empty; otherwise the auto-opened subscription is exposed at `client.autoOpenedSubscription`. `TransportSendOptions` gains `requestSignal` (per-request abort) and `onRequestStreamEnd` (fires when a per-request response stream ends or errors for any non-deliberate reason) on the Streamable HTTP transport. diff --git a/.changeset/subscriptions-listen-result.md b/.changeset/subscriptions-listen-result.md new file mode 100644 index 0000000000..4f62eb608b --- /dev/null +++ b/.changeset/subscriptions-listen-result.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/server': minor +'@modelcontextprotocol/client': minor +--- + +`subscriptions/listen` graceful close: per spec PR #2953, a server-side graceful close (`createMcpHandler` / `serveStdio` `close()`) now emits the empty `subscriptions/listen` JSON-RPC result (the new `SubscriptionsListenResult` — `_meta` carries the subscriptionId) before closing the stream, replacing the previous server-originated `notifications/cancelled`. On the client, `McpSubscription.closed` now resolves `'graceful'` for this signal (added alongside `'local'` and `'remote'`); a stream close without a result remains `'remote'` (unexpected disconnect). diff --git a/.changeset/subscriptions-listen-server.md b/.changeset/subscriptions-listen-server.md new file mode 100644 index 0000000000..abaf0d6261 --- /dev/null +++ b/.changeset/subscriptions-listen-server.md @@ -0,0 +1,17 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/server': minor +--- + +`subscriptions/listen` (SEP-1865) is served by both serving entries on protocol revision 2026-07-28. The entry owns ack-first, per-stream filtering, subscription-id stamping, keepalive (HTTP), the pre-ack `-32603` capacity guard, and teardown (HTTP stream close; one +`notifications/cancelled` per subscription on stdio). `server/discover` now advertises `listChanged`/`subscribe` capability bits — the rider that suppressed them until listen was served is discharged. + +Under `createMcpHandler` the consumer's factory **is** constructed for `subscriptions/listen` (a capabilities-only probe so the acknowledged filter reflects what the server advertises; the instance is never connected and is closed immediately). Per-request authorization performed inside the factory therefore sees listen requests; token verification still belongs at the middleware layer mounted in front of the entry. + +New public surface: + +- `@modelcontextprotocol/server`: `ServerEventBus`, `ServerEvent`, `ServerNotifier` (types); `InMemoryServerEventBus` (class). +- `McpHttpHandler` gains `.notify` (`ServerNotifier`: `toolsChanged()`, `promptsChanged()`, `resourcesChanged()`, `resourceUpdated(uri)`) and `.bus` (the `ServerEventBus` listen streams subscribe to). +- `CreateMcpHandlerOptions` gains `bus?: ServerEventBus` (an in-process `InMemoryServerEventBus` is created when omitted), `maxSubscriptions?: number` (default 1024), and `keepAliveMs?: number` (default 15000). +- `ServeStdioOptions` gains `maxSubscriptions?: number` (default 1024). On a modern-pinned connection `serveStdio` routes the pinned instance's existing `send*ListChanged()` calls onto active subscriptions; legacy connections are unchanged. +- `@modelcontextprotocol/core`: `SUBSCRIPTION_ID_META_KEY` (const); `SubscriptionFilter`, `SubscriptionsListenRequest`, `SubscriptionsListenRequestParams`, `SubscriptionsAcknowledgedNotification`, `SubscriptionsAcknowledgedNotificationParams` (types). diff --git a/.changeset/wire-public-separation.md b/.changeset/wire-public-separation.md new file mode 100644 index 0000000000..7930cc7060 --- /dev/null +++ b/.changeset/wire-public-separation.md @@ -0,0 +1,10 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +Freeze the per-era wire schemas as self-contained copies decoupled from the public types layer, and convert `WireCodec` to a function-only interface. Two small spec-conformance fixes ride along with the otherwise-pure refactor: + +- The 2026 wire-true `resultType` member now defaults to `'complete'` when absent (the spec's receiver-side back-compat rule); the inbound `decodeResult` step continues to require it. The `server/discover` result accepts absent or malformed `ttlMs`/`cacheScope` (falling back to `0`/`'private'` per the spec's receiver leniency in caching.mdx) so the version-negotiation probe classifier stays behavior-neutral. Other cacheable result schemas are unchanged here; general receiver leniency for those belongs to the response-cache surface. +- The sampling `hasTools` discriminant now keys on `tools || toolChoice` (previously `tools` only), aligning the client and server selection of the with-tools result variant with `clientCapabilityRequirements`. 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..719e41f697 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,5 @@ jobs: - run: pnpm run build:all - run: pnpm run test:conformance:server - run: pnpm run test:conformance:server:draft + - run: pnpm run test:conformance:server:extensions + - run: pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:2026 diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml new file mode 100644 index 0000000000..34ec6b152c --- /dev/null +++ b/.github/workflows/examples.yml @@ -0,0 +1,47 @@ +name: Examples + +on: + push: + branches: + - main + - v2-2026-07-28 + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Builds the workspace + examples and e2e-runs every examples// pair + # over every transport it supports. Each client.ts is a self-verifying test + # (asserts and exits non-zero on any mismatch). This is part of the per-PR + # gate basket — a red examples run blocks merge. + examples: + name: examples (build + e2e) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + run_install: false + + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - run: pnpm install + + # The workspace packages the examples import resolve to built dists + # (the gap that killed an earlier examples smoke suite). + - run: pnpm run build:all + + - name: Run all example pairs (transport × era) + run: pnpm tsx scripts/examples/run-examples.ts 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/.prettierrc.json b/.prettierrc.json index 840a2c6b0b..b9a90b2951 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -13,7 +13,8 @@ { "files": "**/*.md", "options": { - "printWidth": 280 + "printWidth": 280, + "proseWrap": "preserve" } } ] diff --git a/CLAUDE.md b/CLAUDE.md index d5a188676a..c2c664b970 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,10 +24,11 @@ pnpm --filter @modelcontextprotocol/core test -- -t "test name" ## Breaking Changes -When making breaking changes, document them in **both**: - -- `docs/migration.md` — human-readable guide with before/after code examples -- `docs/migration-SKILL.md` — LLM-optimized mapping tables for mechanical migration +When making breaking changes, add to the relevant subsystem section in +`docs/migration/upgrade-to-v2.md` (or `docs/migration/support-2026-07-28.md` if the +change is 2026-07-28-only). Mechanical renames go in +`packages/codemod/src/migrations/v1-to-v2/mappings/` and the codemod handles them — do +not reproduce mapping tables in the guide; link to the mapping file instead. Include what changed, why, and how to migrate. Search for related sections and group related changes together rather than adding new standalone sections. @@ -121,11 +122,15 @@ Pluggable JSON Schema validation (`packages/core/src/validators/`): ### Examples -Runnable examples in `examples/`: +Runnable examples in `examples//{server.ts,client.ts}` — each story is its own +`@mcp-examples/` workspace package and a self-verifying e2e test (the client connects, +asserts results, exits non-zero on mismatch). `pnpm run:examples` runs every story over its +configured transport×era legs; the `examples (build + e2e)` CI job is part of the per-PR gate +basket. See `examples/README.md` for the full story matrix. -- `examples/server/src/` - Various server configurations (stateful, stateless, OAuth, etc.) -- `examples/client/src/` - Client examples (basic, OAuth, parallel calls, etc.) -- `examples/shared/src/` - Shared utilities (OAuth demo provider, etc.) +- `examples/shared/` — `@mcp-examples/shared` package. Root export is args-only (`parseExampleArgs`, `check`, `siblingPath`); the demo OAuth provider and `InMemoryEventStore` live at the `@mcp-examples/shared/auth` subpath so non-auth stories don't eagerly evaluate better-auth/express/better-sqlite3. Stories import only this plumbing and inline the SDK transport setup themselves — see `examples/CONTRIBUTING.md`. +- `scripts/examples/` — runner (`run-examples.ts`) +- `examples/guides/` — typecheck-only snippet collections synced into `docs/{server,client}.md` ## Message Flow (Bidirectional Protocol) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 325330c15b..d3d64c4819 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -112,16 +112,19 @@ Then: ### Running Examples -See [`examples/server/README.md`](examples/server/README.md) and [`examples/client/README.md`](examples/client/README.md) for a full list of runnable examples. +See [`examples/README.md`](examples/README.md) for the full list of runnable examples — one self-verifying client/server pair per directory. Quick start: ```bash -# Run a server example -pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts +# Run any story's server +pnpm --filter @mcp-examples/tools server -- --http --port 3000 -# Run a client example (in another terminal) -pnpm --filter @modelcontextprotocol/examples-client exec tsx src/simpleStreamableHttp.ts +# Run its client (in another terminal) +pnpm --filter @mcp-examples/tools client -- --http http://127.0.0.1:3000/ + +# Run every story over every transport × era leg +pnpm run:examples ``` ## Releasing v1.x Patches diff --git a/README.md b/README.md index 55d8fb9d47..7f81e7f4ee 100644 --- a/README.md +++ b/README.md @@ -137,8 +137,7 @@ Ready to build something real? Follow the step-by-step quickstart tutorials: The complete code for each tutorial is in [`examples/server-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server-quickstart/) and [`examples/client-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/client-quickstart/). For more advanced runnable examples, see: -- [`examples/server/README.md`](examples/server/README.md) — server examples index -- [`examples/client/README.md`](examples/client/README.md) — client examples index +- [`examples/README.md`](examples/README.md) — runnable, self-verifying client/server example pairs (one story per directory) ## Documentation diff --git a/REVIEW.md b/REVIEW.md index d210004600..9047acb1c2 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -50,7 +50,7 @@ review rounds and grows over time. **Tests & docs** - New behavior has vitest coverage including error paths -- Breaking changes documented in `docs/migration.md` and `docs/migration-SKILL.md` +- Breaking changes documented in `docs/migration/upgrade-to-v2.md` (or `docs/migration/support-2026-07-28.md` if 2026-only); mechanical renames added to `packages/codemod/src/migrations/v1-to-v2/mappings/` - Bugfix or behavior change: check whether `docs/**/*.md` describes the old behavior and needs updating; flag prose that now contradicts the implementation - New feature: verify prose documentation is added (not just JSDoc), and assess whether `examples/` needs a new or updated example - Behavior change: assess whether existing `examples/` still compile and demonstrate the current API diff --git a/docs/behavior-surface-pins.md b/docs/behavior-surface-pins.md new file mode 100644 index 0000000000..29eb95a848 --- /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/upgrade-to-v2.md` (or `docs/migration/support-2026-07-28.md` if 2026-only) 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/client-quickstart.md b/docs/client-quickstart.md index 71b8a9e12a..f26324636f 100644 --- a/docs/client-quickstart.md +++ b/docs/client-quickstart.md @@ -420,5 +420,5 @@ If you see: Now that you have a working client, here are some ways to go further: - [**Client guide**](./client.md) — Add OAuth, middleware, sampling, and more to your client. -- [**Example clients**](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/client) — Browse runnable client examples. +- [**Example clients**](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) — Browse runnable client examples. - [**FAQ**](./faq.md) — Troubleshoot common errors. diff --git a/docs/client.md b/docs/client.md index c2bb5b05b1..3b7d208b24 100644 --- a/docs/client.md +++ b/docs/client.md @@ -12,8 +12,16 @@ A client connects to a server, discovers what it offers — tools, resources, pr The examples below use these imports. Adjust based on which features and transport you need: -```ts source="../examples/client/src/clientGuide.examples.ts#imports" -import type { AuthProvider, Prompt, Resource, Tool } from '@modelcontextprotocol/client'; +```ts source="../examples/guides/clientGuide.examples.ts#imports" +import type { + AuthProvider, + OAuthClientInformationContext, + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthClientProvider, + OAuthDiscoveryState, + OAuthTokens +} from '@modelcontextprotocol/client'; import { applyMiddlewares, Client, @@ -21,6 +29,7 @@ import { createMiddleware, CrossAppAccessProvider, discoverAndRequestJwtAuthGrant, + IssuerMismatchError, PrivateKeyJwtProvider, ProtocolError, SdkError, @@ -28,7 +37,8 @@ import { SSEClientTransport, StreamableHTTPClientTransport, TRACEPARENT_META_KEY, - TRACESTATE_META_KEY + TRACESTATE_META_KEY, + UnauthorizedError } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; ``` @@ -39,7 +49,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; For remote HTTP servers, use {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport}: -```ts source="../examples/client/src/clientGuide.examples.ts#connect_streamableHttp" +```ts source="../examples/guides/clientGuide.examples.ts#connect_streamableHttp" const client = new Client({ name: 'my-client', version: '1.0.0' }); const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp')); @@ -47,13 +57,13 @@ const transport = new StreamableHTTPClientTransport(new URL('http://localhost:30 await client.connect(transport); ``` -For a full interactive client over Streamable HTTP, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleStreamableHttp.ts). +For a full interactive client over Streamable HTTP, see [`repl/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/repl/client.ts). ### stdio For local, process-spawned servers (Claude Desktop, CLI tools), use {@linkcode @modelcontextprotocol/client!client/stdio.StdioClientTransport | StdioClientTransport}. The transport spawns the server process and communicates over stdin/stdout: -```ts source="../examples/client/src/clientGuide.examples.ts#connect_stdio" +```ts source="../examples/guides/clientGuide.examples.ts#connect_stdio" const client = new Client({ name: 'my-client', version: '1.0.0' }); const transport = new StdioClientTransport({ @@ -66,9 +76,10 @@ await client.connect(transport); ### SSE fallback for legacy servers -To support both modern Streamable HTTP and legacy SSE servers, try {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport} first and fall back to {@linkcode @modelcontextprotocol/client!client/sse.SSEClientTransport | SSEClientTransport} on failure: +To support both modern Streamable HTTP and legacy SSE servers, try {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport} first and fall back to {@linkcode +@modelcontextprotocol/client!client/sse.SSEClientTransport | SSEClientTransport} on failure: -```ts source="../examples/client/src/clientGuide.examples.ts#connect_sseFallback" +```ts source="../examples/guides/clientGuide.examples.ts#connect_sseFallback" const baseUrl = new URL(url); try { @@ -86,7 +97,48 @@ try { } ``` -For a complete example with error reporting, see [`streamableHttpWithSseFallbackClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/streamableHttpWithSseFallbackClient.ts). +The snippet above is the complete pattern; wrap the `catch` body with whatever error reporting your host needs. + +### Protocol version negotiation (2026-07-28 revision) + +By default the client negotiates a 2025-era protocol version via the `initialize` handshake — exactly the v1.x behavior, byte for byte. To talk to a server on the 2026-07-28 revision, opt into version negotiation via `ClientOptions.versionNegotiation`: + +```ts source="../examples/guides/clientGuide.examples.ts#Client_versionNegotiation" +// Auto-negotiate: probe with server/discover, fall back to the 2025 handshake +// against a 2025-only server. +const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); +await client.connect(transport); + +client.getProtocolEra(); // 'modern' or 'legacy' +client.getNegotiatedProtocolVersion(); // '2026-07-28' or '2025-11-25' +``` + +- **absent / `mode: 'legacy'` (the default)** — today's 2025 connect sequence; no probe, no new headers. +- **`mode: 'auto'`** — `connect()` probes with `server/discover`; a 2025-only server rejects the probe and the client falls back to the plain `initialize` handshake on the same connection, byte-equivalent to a 2025 client. The probe costs one round trip against an old server. +- **`mode: { pin: '2026-07-28' }`** — modern era at exactly that revision; no fallback. Against a 2025-only server `connect()` rejects with a typed error. Use `pin` where a silent downgrade would be worse than an error (tests, CI, servers you control). + +Once a modern era is negotiated, the client automatically attaches the per-request `_meta` envelope (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification. You can also configure negotiation pre-connect on an +already-constructed instance via {@linkcode @modelcontextprotocol/client!client/client.Client#setVersionNegotiation | client.setVersionNegotiation()}. See the [2026-07-28 support guide › Probe policy](./migration/support-2026-07-28.md#probe-policy) for the full failure semantics and probe-timeout behavior. + +#### Skipping the probe: `connect({ prior })` + +A gateway, proxy, or worker fleet that already knows the server's `server/discover` advertisement can skip the probe entirely. Pass a previously-obtained {@linkcode @modelcontextprotocol/client!index.DiscoverResult | DiscoverResult} via +{@linkcode @modelcontextprotocol/client!client/client.ConnectOptions | ConnectOptions.prior} and `connect()` adopts it directly with **zero round trips** — the 2026-07-28 protocol is stateless on HTTP, so once the advertisement is known there is nothing left to negotiate. + +```ts source="../examples/guides/clientGuide.examples.ts#Client_connect_prior" +// Probe once (here via the 'auto'-mode connect), persist the result … +const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); +await bootstrap.connect(new StreamableHTTPClientTransport(url)); +const persisted = JSON.stringify(bootstrap.getDiscoverResult()); + +// … then every worker connects with zero round trips. +const worker = new Client({ name: 'worker', version: '1.0.0' }); +await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) }); +``` + +{@linkcode @modelcontextprotocol/client!client/client.Client#getDiscoverResult | client.getDiscoverResult()} returns the value that the `'auto'`/pinned probe path, an explicit {@linkcode @modelcontextprotocol/client!client/client.Client#discover | client.discover()} call, or a +prior `connect({ prior })` recorded; it round-trips through `JSON.stringify`/`JSON.parse`. `connect({ prior })` is **2026-07-28+ only** — it rejects with `SdkError(EraNegotiationFailed)` when the supplied result and the client share no modern revision. Only reuse a persisted +`DiscoverResult` across clients that present the **same authorization context** as the one that obtained it. See the [`gateway/` example](../examples/gateway/README.md) for the full probe-once / connect-many pattern with a server-side proof. ### Disconnecting @@ -94,7 +146,7 @@ Call {@linkcode @modelcontextprotocol/client!client/client.Client#close | await For Streamable HTTP, terminate the server-side session first (per the MCP specification): -```ts source="../examples/client/src/clientGuide.examples.ts#disconnect_streamableHttp" +```ts source="../examples/guides/clientGuide.examples.ts#disconnect_streamableHttp" await transport.terminateSession(); // notify the server (recommended) await client.close(); ``` @@ -103,9 +155,10 @@ For stdio, `client.close()` handles graceful process shutdown (closes stdin, the ### Server instructions -Servers can provide an `instructions` string during initialization that describes how to use them — cross-tool relationships, workflow patterns, and constraints (see [Instructions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#instructions) in the MCP specification). Retrieve it after connecting and include it in the model's system prompt: +Servers can provide an `instructions` string during initialization that describes how to use them — cross-tool relationships, workflow patterns, and constraints (see [Instructions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#instructions) in the MCP +specification). Retrieve it after connecting and include it in the model's system prompt: -```ts source="../examples/client/src/clientGuide.examples.ts#serverInstructions_basic" +```ts source="../examples/guides/clientGuide.examples.ts#serverInstructions_basic" const instructions = client.getInstructions(); const systemPrompt = ['You are a helpful assistant.', instructions].filter(Boolean).join('\n\n'); @@ -115,25 +168,27 @@ console.log(systemPrompt); ## Authentication -MCP servers can require authentication before accepting client connections (see [Authorization](https://modelcontextprotocol.io/specification/latest/basic/authorization) in the MCP specification). Pass an {@linkcode @modelcontextprotocol/client!client/auth.AuthProvider | AuthProvider} to {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport}. The transport calls `token()` before every request and `onUnauthorized()` (if provided) on 401, then retries once. +MCP servers can require authentication before accepting client connections (see [Authorization](https://modelcontextprotocol.io/specification/latest/basic/authorization) in the MCP specification). Pass an {@linkcode @modelcontextprotocol/client!client/auth.AuthProvider | +AuthProvider} to {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport}. The transport calls `token()` before every request and `onUnauthorized()` (if provided) on 401, then retries once. ### Bearer tokens -For servers that accept bearer tokens managed outside the SDK — API keys, tokens from a gateway or proxy, service-account credentials — implement only `token()`. With no `onUnauthorized()`, a 401 throws {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} immediately: +For servers that accept bearer tokens managed outside the SDK — API keys, tokens from a gateway or proxy, service-account credentials — implement only `token()`. With no `onUnauthorized()`, a 401 throws {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | +UnauthorizedError} immediately: -```ts source="../examples/client/src/clientGuide.examples.ts#auth_tokenProvider" +```ts source="../examples/guides/clientGuide.examples.ts#auth_tokenProvider" const authProvider: AuthProvider = { token: async () => getStoredToken() }; const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); ``` -See [`simpleTokenProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleTokenProvider.ts) for a complete runnable example. +See [`simpleTokenProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleTokenProvider.ts) for a complete runnable example. ### Client credentials {@linkcode @modelcontextprotocol/client!client/authExtensions.ClientCredentialsProvider | ClientCredentialsProvider} handles the `client_credentials` grant flow for service-to-service communication: -```ts source="../examples/client/src/clientGuide.examples.ts#auth_clientCredentials" +```ts source="../examples/guides/clientGuide.examples.ts#auth_clientCredentials" const authProvider = new ClientCredentialsProvider({ clientId: 'my-service', clientSecret: 'my-secret' @@ -150,7 +205,7 @@ await client.connect(transport); {@linkcode @modelcontextprotocol/client!client/authExtensions.PrivateKeyJwtProvider | PrivateKeyJwtProvider} signs JWT assertions for the `private_key_jwt` token endpoint auth method, avoiding a shared client secret: -```ts source="../examples/client/src/clientGuide.examples.ts#auth_privateKeyJwt" +```ts source="../examples/guides/clientGuide.examples.ts#auth_privateKeyJwt" const authProvider = new PrivateKeyJwtProvider({ clientId: 'my-service', privateKey: pemEncodedKey, @@ -160,23 +215,134 @@ const authProvider = new PrivateKeyJwtProvider({ const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); ``` -For a runnable example supporting both auth methods via environment variables, see [`simpleClientCredentials.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleClientCredentials.ts). +For a runnable `client_credentials` example, see [`oauth-client-credentials/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth-client-credentials/client.ts) — its README shows the `private_key_jwt` swap (the in-repo demo Authorization +Server only implements `client_secret_basic`/`client_secret_post`, so there is no runnable `private_key_jwt` leg). ### Full OAuth with user authorization -For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). The {@linkcode @modelcontextprotocol/client!client/client.Client#connect | connect()} call will throw {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, call {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(code)}, and reconnect. +For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). Key persisted +client credentials by the `ctx.issuer` passed to `clientInformation()` / `saveClientInformation()` so credentials registered with one authorization server are never sent to another: + +```ts source="../examples/guides/clientGuide.examples.ts#auth_oauthClientProvider" +class MyOAuthProvider implements OAuthClientProvider { + // Key DCR-obtained credentials by issuer so a client_id registered with one + // authorization server is never returned for another (SEP-2352). + private creds = new Map(); + private storedTokens?: OAuthTokens; + private verifier?: string; + private discovery?: OAuthDiscoveryState; + lastState?: string; + + readonly redirectUrl = 'http://localhost:8090/callback'; + readonly clientMetadata: OAuthClientMetadata = { + client_name: 'My MCP Client', + redirect_uris: ['http://localhost:8090/callback'], + // Loopback redirect → the SDK would default this to 'native'; set + // explicitly when the heuristic is wrong for your deployment (SEP-837). + application_type: 'native' + }; -For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClient.ts) and [`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClientProvider.ts). + clientInformation(ctx?: OAuthClientInformationContext) { + return ctx ? this.creds.get(ctx.issuer) : undefined; + } + saveClientInformation(info: OAuthClientInformationMixed, ctx?: OAuthClientInformationContext) { + if (ctx) this.creds.set(ctx.issuer, info); + } + tokens() { + return this.storedTokens; + } + saveTokens(tokens: OAuthTokens) { + // In production, persist to OS keychain / secure storage — never plain files. + this.storedTokens = tokens; + } + // CSRF binding for the redirect — the SDK puts this on the authorize URL; + // your callback handler compares it before calling `finishAuth`. + state() { + this.lastState = crypto.randomUUID(); + return this.lastState; + } + // Callback-leg AS-binding (SEP-2352): record what discovery resolved before + // the redirect so the SDK can verify the code is exchanged at the same AS. + saveDiscoveryState(state: OAuthDiscoveryState) { + this.discovery = state; + } + discoveryState() { + return this.discovery; + } + redirectToAuthorization(url: URL) { + onRedirect(url); + } + saveCodeVerifier(v: string) { + this.verifier = v; + } + codeVerifier() { + if (!this.verifier) throw new Error('no code verifier'); + return this.verifier; + } +} + +const provider = new MyOAuthProvider(); +const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { + authProvider: provider +}); +``` + +The {@linkcode @modelcontextprotocol/client!client/client.Client#connect | connect()} call throws {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, hand the callback query +to {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth()}, and reconnect. Passing the whole `URLSearchParams` lets the SDK extract `code` and validate the RFC 9207 `iss` parameter for you: + +```ts source="../examples/guides/clientGuide.examples.ts#auth_finishAuth" +const client = new Client({ name: 'my-client', version: '1.0.0' }); +const transport = new StreamableHTTPClientTransport(url, { authProvider: provider }); +try { + await client.connect(transport); + return client; +} catch (error) { + // With version negotiation, the connect-time 401 may surface wrapped as + // SdkError(EraNegotiationFailed) whose .data.cause is the UnauthorizedError. + const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause; + if (!(root instanceof UnauthorizedError)) throw error; + // The transport called redirectToAuthorization(); fall through to the browser callback. +} + +const callbackUrl = await waitForCallback(); +const params = new URL(callbackUrl).searchParams; + +// The SDK does not validate `state` — compare it to the value your provider generated. +if (params.get('state') !== provider.lastState) throw new Error('state mismatch'); + +try { + // Preferred: hand over the whole query — the SDK extracts `code` and + // `iss`, validates `iss` (RFC 9207), and never surfaces callback-derived + // `error`/`error_description` text on mismatch. + await transport.finishAuth(params); +} catch (error) { + if (error instanceof IssuerMismatchError) { + // Mix-up attack: do NOT render params.get('error_description') to the user. + throw new Error('Authorization failed: issuer mismatch'); + } + throw error; +} + +// Reconnect on a FRESH transport — a started transport cannot be restarted; +// OAuth state (tokens, verifier, discovery) lives on the provider, not the transport. +await client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider })); +return client; +``` + +For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClient.ts) and +[`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClientProvider.ts). ### Cross-App Access (Enterprise Managed Authorization) -{@linkcode @modelcontextprotocol/client!client/authExtensions.CrossAppAccessProvider | CrossAppAccessProvider} implements Enterprise Managed Authorization (SEP-990) for scenarios where users authenticate with an enterprise identity provider (IdP) and clients need to access protected MCP servers on their behalf. +{@linkcode @modelcontextprotocol/client!client/authExtensions.CrossAppAccessProvider | CrossAppAccessProvider} implements Enterprise Managed Authorization (SEP-990) for scenarios where users authenticate with an enterprise identity provider (IdP) and clients need to access +protected MCP servers on their behalf. This provider handles a two-step OAuth flow: + 1. Exchange the user's ID Token from the enterprise IdP for a JWT Authorization Grant (JAG) via RFC 8693 token exchange 2. Exchange the JAG for an access token from the MCP server via RFC 7523 JWT bearer grant -```ts source="../examples/client/src/clientGuide.examples.ts#auth_crossAppAccess" +```ts source="../examples/guides/clientGuide.examples.ts#auth_crossAppAccess" const authProvider = new CrossAppAccessProvider({ assertion: async ctx => { // ctx provides: authorizationServerUrl, resourceUrl, scope, fetchFn @@ -200,36 +366,34 @@ const transport = new StreamableHTTPClientTransport(new URL('http://localhost:30 ``` The `assertion` callback receives a context object with: + - `authorizationServerUrl` – The MCP server's authorization server (discovered automatically) - `resourceUrl` – The MCP resource URL (discovered automatically) - `scope` – Optional scope passed to `auth()` or from `clientMetadata` - `fetchFn` – Fetch implementation to use for HTTP requests For manual control over the token exchange steps, use the Layer 2 utilities from `@modelcontextprotocol/client`: + - `requestJwtAuthorizationGrant()` – Exchange ID Token for JAG at IdP - `discoverAndRequestJwtAuthGrant()` – Discovery + JAG acquisition - `exchangeJwtAuthGrant()` – Exchange JAG for access token at MCP server > [!NOTE] -> See [RFC 8693 (Token Exchange)](https://datatracker.ietf.org/doc/html/rfc8693), [RFC 7523 (JWT Bearer Grant)](https://datatracker.ietf.org/doc/html/rfc7523), and [RFC 9728 (Resource Discovery)](https://datatracker.ietf.org/doc/html/rfc9728) for the underlying OAuth standards. +> See [RFC 8693 (Token Exchange)](https://datatracker.ietf.org/doc/html/rfc8693), [RFC 7523 (JWT Bearer Grant)](https://datatracker.ietf.org/doc/html/rfc7523), and [RFC 9728 (Resource Discovery)](https://datatracker.ietf.org/doc/html/rfc9728) for the underlying OAuth +> standards. ## Tools Tools are callable actions offered by servers — discovering and invoking them is usually how your client enables an LLM to take action (see [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) in the MCP overview). -Use {@linkcode @modelcontextprotocol/client!client/client.Client#listTools | listTools()} to discover available tools, and {@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} to invoke one. Results may be paginated — loop on `nextCursor` to collect all pages: +Use {@linkcode @modelcontextprotocol/client!client/client.Client#listTools | listTools()} to discover available tools, and {@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} to invoke one. `listTools()` walks every page on your behalf and returns +the complete list (pass an explicit `{ cursor }` for per-page control): -```ts source="../examples/client/src/clientGuide.examples.ts#callTool_basic" -const allTools: Tool[] = []; -let toolCursor: string | undefined; -do { - const { tools, nextCursor } = await client.listTools({ cursor: toolCursor }); - allTools.push(...tools); - toolCursor = nextCursor; -} while (toolCursor); +```ts source="../examples/guides/clientGuide.examples.ts#callTool_basic" +const { tools } = await client.listTools(); console.log( 'Available tools:', - allTools.map(t => t.name) + tools.map(t => t.name) ); const result = await client.callTool({ @@ -239,17 +403,21 @@ const result = await client.callTool({ console.log(result.content); ``` -Tool results may include a `structuredContent` field — a machine-readable JSON object for programmatic use by the client application, complementing `content` which is for the LLM: +Tool results may include a `structuredContent` field — a machine-readable JSON value (any JSON type per SEP-2106) for programmatic use by the client application, complementing `content` which is for the LLM: -```ts source="../examples/client/src/clientGuide.examples.ts#callTool_structuredOutput" +```ts source="../examples/guides/clientGuide.examples.ts#callTool_structuredOutput" const result = await client.callTool({ name: 'calculate-bmi', arguments: { weightKg: 70, heightM: 1.75 } }); -// Machine-readable output for the client application -if (result.structuredContent) { - console.log(result.structuredContent); // e.g. { bmi: 22.86 } +// Machine-readable output for the client application. SEP-2106: structuredContent is +// `unknown` (any JSON value). Check for presence with `!== undefined` and narrow before use. +if (result.structuredContent !== undefined) { + const sc: unknown = result.structuredContent; // e.g. { bmi: 22.86 } + if (typeof sc === 'object' && sc !== null && 'bmi' in sc) { + console.log(sc.bmi); + } } ``` @@ -257,7 +425,7 @@ if (result.structuredContent) { Pass `onprogress` to receive incremental progress notifications from long-running tools. Use `resetTimeoutOnProgress` to keep the request alive while the server is actively reporting, and `maxTotalTimeout` as an absolute cap: -```ts source="../examples/client/src/clientGuide.examples.ts#callTool_progress" +```ts source="../examples/guides/clientGuide.examples.ts#callTool_progress" const result = await client.callTool( { name: 'long-operation', arguments: {} }, { @@ -271,23 +439,28 @@ const result = await client.callTool( console.log(result.content); ``` +### `x-mcp-header` parameter mirroring (2026-07-28 draft) + +On a 2026-07-28 connection over Streamable HTTP, `callTool()` mirrors any argument whose `inputSchema` property carries an `x-mcp-header` annotation into an `Mcp-Param-{Name}` HTTP request header so intermediaries can route on it without parsing the body. The mirrored headers +are built from the client's internal `tools/list` cache; if you already hold the tool definition (e.g. from configuration), pass it via `CallToolRequestOptions.toolDefinition` so mirroring runs without a prior list. On a cache miss the call is sent without `Mcp-Param-*` headers +and, when a conforming server rejects it with `-32020` (`HeaderMismatch`), `callTool()` refreshes the definition cache once and retries. + +On a non-stdio modern connection `listTools()` (and the internal `tools/list` cache) exclude tool definitions whose `x-mcp-header` declarations violate the spec's constraints, logging a warning that names the tool and the reason. Browser clients skip mirroring (dynamically named +headers cannot be statically allow-listed for credentialed CORS), so calling an `x-mcp-header` tool with a non-null designated argument from a browser against a server that enforces SEP-2243 validation will be rejected — a known limitation. The legacy-era `callTool`/`listTools` +paths are unchanged. + ## Resources Resources are read-only data — files, database schemas, configuration — that your application can retrieve from a server and attach as context for the model (see [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources) in the MCP overview). -Use {@linkcode @modelcontextprotocol/client!client/client.Client#listResources | listResources()} and {@linkcode @modelcontextprotocol/client!client/client.Client#readResource | readResource()} to discover and read server-provided data. Results may be paginated — loop on `nextCursor` to collect all pages: +Use {@linkcode @modelcontextprotocol/client!client/client.Client#listResources | listResources()} and {@linkcode @modelcontextprotocol/client!client/client.Client#readResource | readResource()} to discover and read server-provided data. `listResources()` walks every page on your +behalf and returns the complete list (pass an explicit `{ cursor }` for per-page control): -```ts source="../examples/client/src/clientGuide.examples.ts#readResource_basic" -const allResources: Resource[] = []; -let resourceCursor: string | undefined; -do { - const { resources, nextCursor } = await client.listResources({ cursor: resourceCursor }); - allResources.push(...resources); - resourceCursor = nextCursor; -} while (resourceCursor); +```ts source="../examples/guides/clientGuide.examples.ts#readResource_basic" +const { resources } = await client.listResources(); console.log( 'Available resources:', - allResources.map(r => r.name) + resources.map(r => r.name) ); const { contents } = await client.readResource({ uri: 'config://app' }); @@ -302,7 +475,7 @@ To discover URI templates for dynamic resources, use {@linkcode @modelcontextpro If the server supports resource subscriptions, use {@linkcode @modelcontextprotocol/client!client/client.Client#subscribeResource | subscribeResource()} to receive notifications when a resource changes, then re-read it: -```ts source="../examples/client/src/clientGuide.examples.ts#subscribeResource_basic" +```ts source="../examples/guides/clientGuide.examples.ts#subscribeResource_basic" await client.subscribeResource({ uri: 'config://app' }); client.setNotificationHandler('notifications/resources/updated', async notification => { @@ -320,19 +493,14 @@ await client.unsubscribeResource({ uri: 'config://app' }); Prompts are reusable message templates that servers offer to help structure interactions with models (see [Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) in the MCP overview). -Use {@linkcode @modelcontextprotocol/client!client/client.Client#listPrompts | listPrompts()} and {@linkcode @modelcontextprotocol/client!client/client.Client#getPrompt | getPrompt()} to list available prompts and retrieve them with arguments. Results may be paginated — loop on `nextCursor` to collect all pages: +Use {@linkcode @modelcontextprotocol/client!client/client.Client#listPrompts | listPrompts()} and {@linkcode @modelcontextprotocol/client!client/client.Client#getPrompt | getPrompt()} to list available prompts and retrieve them with arguments. `listPrompts()` walks every page on +your behalf and returns the complete list (pass an explicit `{ cursor }` for per-page control): -```ts source="../examples/client/src/clientGuide.examples.ts#getPrompt_basic" -const allPrompts: Prompt[] = []; -let promptCursor: string | undefined; -do { - const { prompts, nextCursor } = await client.listPrompts({ cursor: promptCursor }); - allPrompts.push(...prompts); - promptCursor = nextCursor; -} while (promptCursor); +```ts source="../examples/guides/clientGuide.examples.ts#getPrompt_basic" +const { prompts } = await client.listPrompts(); console.log( 'Available prompts:', - allPrompts.map(p => p.name) + prompts.map(p => p.name) ); const { messages } = await client.getPrompt({ @@ -346,7 +514,7 @@ console.log(messages); Both prompts and resources can support argument completions. Use {@linkcode @modelcontextprotocol/client!client/client.Client#complete | complete()} to request autocompletion suggestions from the server as a user types: -```ts source="../examples/client/src/clientGuide.examples.ts#complete_basic" +```ts source="../examples/guides/clientGuide.examples.ts#complete_basic" const { completion } = await client.complete({ ref: { type: 'ref/prompt', @@ -364,9 +532,10 @@ console.log(completion.values); // e.g. ['typescript'] ### Automatic list-change tracking -The {@linkcode @modelcontextprotocol/client!client/client.ClientOptions | listChanged} client option keeps a local cache of tools, prompts, or resources in sync with the server. It provides automatic server capability gating, debouncing (300 ms by default), auto-refresh, and error-first callbacks: +The {@linkcode @modelcontextprotocol/client!client/client.ClientOptions | listChanged} client option keeps a local cache of tools, prompts, or resources in sync with the server. It provides automatic server capability gating, debouncing (300 ms by default), auto-refresh, and +error-first callbacks: -```ts source="../examples/client/src/clientGuide.examples.ts#listChanged_basic" +```ts source="../examples/guides/clientGuide.examples.ts#listChanged_basic" const client = new Client( { name: 'my-client', version: '1.0.0' }, { @@ -392,7 +561,7 @@ const client = new Client( For full control — or for notification types not covered by `listChanged` (such as log messages) — register handlers directly with {@linkcode @modelcontextprotocol/client!client/client.Client#setNotificationHandler | setNotificationHandler()}: -```ts source="../examples/client/src/clientGuide.examples.ts#notificationHandler_basic" +```ts source="../examples/guides/clientGuide.examples.ts#notificationHandler_basic" // Server log messages (sent by the server during request processing) client.setNotificationHandler('notifications/message', notification => { const { level, data } = notification.params; @@ -407,11 +576,12 @@ client.setNotificationHandler('notifications/resources/list_changed', async () = ``` > [!WARNING] -> MCP logging (including `setLoggingLevel()` and `notifications/message`) is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Servers should migrate to stderr logging (STDIO) or OpenTelemetry. +> MCP logging (including `setLoggingLevel()` and `notifications/message`) is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the +> [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Servers should migrate to stderr logging (STDIO) or OpenTelemetry. To control the minimum severity of log messages the server sends, use {@linkcode @modelcontextprotocol/client!client/client.Client#setLoggingLevel | setLoggingLevel()}: -```ts source="../examples/client/src/clientGuide.examples.ts#setLoggingLevel_basic" +```ts source="../examples/guides/clientGuide.examples.ts#setLoggingLevel_basic" await client.setLoggingLevel('warning'); ``` @@ -420,9 +590,10 @@ await client.setLoggingLevel('warning'); ## Handling server-initiated requests -MCP is bidirectional — servers can send requests *to* the client during tool execution, as long as the client declares matching capabilities (see [Architecture](https://modelcontextprotocol.io/docs/learn/architecture) in the MCP overview). Declare the corresponding capability when constructing the {@linkcode @modelcontextprotocol/client!client/client.Client | Client} and register a request handler: +MCP is bidirectional — servers can send requests _to_ the client during tool execution, as long as the client declares matching capabilities (see [Architecture](https://modelcontextprotocol.io/docs/learn/architecture) in the MCP overview). Declare the corresponding capability +when constructing the {@linkcode @modelcontextprotocol/client!client/client.Client | Client} and register a request handler: -```ts source="../examples/client/src/clientGuide.examples.ts#capabilities_declaration" +```ts source="../examples/guides/clientGuide.examples.ts#capabilities_declaration" const client = new Client( { name: 'my-client', version: '1.0.0' }, { @@ -437,11 +608,12 @@ const client = new Client( ### Sampling > [!WARNING] -> Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Servers should migrate to calling LLM provider APIs directly. +> Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Servers +> should migrate to calling LLM provider APIs directly. When a server needs an LLM completion during tool execution, it sends a `sampling/createMessage` request to the client (see [Sampling](https://modelcontextprotocol.io/docs/learn/client-concepts#sampling) in the MCP overview). Register a handler to fulfill it: -```ts source="../examples/client/src/clientGuide.examples.ts#sampling_handler" +```ts source="../examples/guides/clientGuide.examples.ts#sampling_handler" client.setRequestHandler('sampling/createMessage', async request => { const lastMessage = request.params.messages.at(-1); console.log('Sampling request:', lastMessage); @@ -460,9 +632,10 @@ client.setRequestHandler('sampling/createMessage', async request => { ### Elicitation -When a server needs user input during tool execution, it sends an `elicitation/create` request to the client (see [Elicitation](https://modelcontextprotocol.io/docs/learn/client-concepts#elicitation) in the MCP overview). The client should present the form to the user and return the collected data, or `{ action: 'decline' }`: +When a server needs user input during tool execution, it sends an `elicitation/create` request to the client (see [Elicitation](https://modelcontextprotocol.io/docs/learn/client-concepts#elicitation) in the MCP overview). The client should present the form to the user and return +the collected data, or `{ action: 'decline' }`: -```ts source="../examples/client/src/clientGuide.examples.ts#elicitation_handler" +```ts source="../examples/guides/clientGuide.examples.ts#elicitation_handler" client.setRequestHandler('elicitation/create', async request => { console.log('Server asks:', request.params.message); @@ -476,16 +649,18 @@ client.setRequestHandler('elicitation/create', async request => { }); ``` -For a full form-based elicitation handler with AJV validation, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleStreamableHttp.ts). For URL elicitation mode, see [`elicitationUrlExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/elicitationUrlExample.ts). +For a full form-based elicitation handler with AJV validation, see [`repl/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/repl/client.ts). For URL elicitation mode (both the 2025-era push/throw style and the 2026-07-28 `inputRequired` +return), see [`elicitation/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/elicitation/client.ts). ### Roots > [!WARNING] -> Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to passing paths via tool parameters, resource URIs, or configuration. +> Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to +> passing paths via tool parameters, resource URIs, or configuration. Roots let the client expose filesystem boundaries to the server (see [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) in the MCP overview). Declare the `roots` capability and register a `roots/list` handler: -```ts source="../examples/client/src/clientGuide.examples.ts#roots_handler" +```ts source="../examples/guides/clientGuide.examples.ts#roots_handler" client.setRequestHandler('roots/list', async () => { return { roots: [ @@ -502,9 +677,9 @@ When the available roots change, notify the server with {@linkcode @modelcontext ### Tool errors vs protocol errors -{@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} has two error surfaces: the tool can *run but report failure* via `isError: true` in the result, or the *request itself can fail* and throw an exception. Always check both: +{@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} has two error surfaces: the tool can _run but report failure_ via `isError: true` in the result, or the _request itself can fail_ and throw an exception. Always check both: -```ts source="../examples/client/src/clientGuide.examples.ts#errorHandling_toolErrors" +```ts source="../examples/guides/clientGuide.examples.ts#errorHandling_toolErrors" try { const result = await client.callTool({ name: 'fetch-data', @@ -530,13 +705,16 @@ try { } ``` -{@linkcode @modelcontextprotocol/client!index.ProtocolError | ProtocolError} represents JSON-RPC errors from the server (method not found, invalid params, internal error). {@linkcode @modelcontextprotocol/client!index.SdkError | SdkError} represents local SDK errors — {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | REQUEST_TIMEOUT}, {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED}, {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.CapabilityNotSupported | CAPABILITY_NOT_SUPPORTED}, and others. +{@linkcode @modelcontextprotocol/client!index.ProtocolError | ProtocolError} represents JSON-RPC errors from the server (method not found, invalid params, internal error). {@linkcode @modelcontextprotocol/client!index.SdkError | SdkError} represents local SDK errors — {@linkcode +@modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | REQUEST_TIMEOUT}, {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED}, {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.CapabilityNotSupported | +CAPABILITY_NOT_SUPPORTED}, and others. ### Connection lifecycle -Set {@linkcode @modelcontextprotocol/client!client/client.Client#onerror | client.onerror} to catch out-of-band transport errors (SSE disconnects, parse errors). Set {@linkcode @modelcontextprotocol/client!client/client.Client#onclose | client.onclose} to detect when the connection drops — pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error: +Set {@linkcode @modelcontextprotocol/client!client/client.Client#onerror | client.onerror} to catch out-of-band transport errors (SSE disconnects, parse errors). Set {@linkcode @modelcontextprotocol/client!client/client.Client#onclose | client.onclose} to detect when the +connection drops — pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error: -```ts source="../examples/client/src/clientGuide.examples.ts#errorHandling_lifecycle" +```ts source="../examples/guides/clientGuide.examples.ts#errorHandling_lifecycle" // Out-of-band errors (SSE disconnects, parse errors) client.onerror = error => { console.error('Transport error:', error.message); @@ -550,9 +728,10 @@ client.onclose = () => { ### Timeouts -All requests have a 60-second default timeout. Pass a custom `timeout` in the options to override it. On timeout, the SDK sends a cancellation notification to the server and rejects the promise with {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | SdkErrorCode.RequestTimeout}: +All requests have a 60-second default timeout. Pass a custom `timeout` in the options to override it. On timeout, the SDK sends a cancellation notification to the server and rejects the promise with {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | +SdkErrorCode.RequestTimeout}: -```ts source="../examples/client/src/clientGuide.examples.ts#errorHandling_timeout" +```ts source="../examples/guides/clientGuide.examples.ts#errorHandling_timeout" try { const result = await client.callTool( { name: 'slow-operation', arguments: {} }, @@ -568,9 +747,10 @@ try { ## Client middleware -Use {@linkcode @modelcontextprotocol/client!client/middleware.createMiddleware | createMiddleware()} and {@linkcode @modelcontextprotocol/client!client/middleware.applyMiddlewares | applyMiddlewares()} to compose fetch middleware pipelines. Middleware wraps the underlying `fetch` call and can add headers, handle retries, or log requests. Pass the enhanced fetch to the transport via the `fetch` option: +Use {@linkcode @modelcontextprotocol/client!client/middleware.createMiddleware | createMiddleware()} and {@linkcode @modelcontextprotocol/client!client/middleware.applyMiddlewares | applyMiddlewares()} to compose fetch middleware pipelines. Middleware wraps the underlying `fetch` +call and can add headers, handle retries, or log requests. Pass the enhanced fetch to the transport via the `fetch` option: -```ts source="../examples/client/src/clientGuide.examples.ts#middleware_basic" +```ts source="../examples/guides/clientGuide.examples.ts#middleware_basic" const authMiddleware = createMiddleware(async (next, input, init) => { const headers = new Headers(init?.headers); headers.set('X-Custom-Header', 'my-value'); @@ -584,11 +764,13 @@ const transport = new StreamableHTTPClientTransport(new URL('http://localhost:30 ## Trace context propagation -The MCP specification ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)) reserves the unprefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` for distributed trace context, as an exception to the usual `_meta` key prefix rule. When present, the values must follow the [W3C Trace Context](https://www.w3.org/TR/trace-context/) and [W3C Baggage](https://www.w3.org/TR/baggage/) formats. The SDK does not interpret these keys — `_meta` passes through both directions untouched — so you can propagate OpenTelemetry context across any transport, including stdio where HTTP headers are unavailable. The key names are exported as `TRACEPARENT_META_KEY`, `TRACESTATE_META_KEY`, and `BAGGAGE_META_KEY`. +The MCP specification ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)) reserves the unprefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` for distributed trace context, as an exception to the usual `_meta` key prefix rule. When +present, the values must follow the [W3C Trace Context](https://www.w3.org/TR/trace-context/) and [W3C Baggage](https://www.w3.org/TR/baggage/) formats. The SDK does not interpret these keys — `_meta` passes through both directions untouched — so you can propagate OpenTelemetry +context across any transport, including stdio where HTTP headers are unavailable. The key names are exported as `TRACEPARENT_META_KEY`, `TRACESTATE_META_KEY`, and `BAGGAGE_META_KEY`. Attach trace context to a single request via `_meta`: -```ts source="../examples/client/src/clientGuide.examples.ts#traceContext_perRequest" +```ts source="../examples/guides/clientGuide.examples.ts#traceContext_perRequest" // Values would normally come from your tracer's active span context. const result = await client.callTool({ name: 'calculate-bmi', @@ -603,7 +785,7 @@ console.log(result.content); Or inject it into every outgoing request with fetch middleware (Streamable HTTP transport): -```ts source="../examples/client/src/clientGuide.examples.ts#traceContext_middleware" +```ts source="../examples/guides/clientGuide.examples.ts#traceContext_middleware" const traceContextMiddleware = createMiddleware(async (next, input, init) => { if (typeof init?.body !== 'string') { return next(input, init); @@ -638,7 +820,7 @@ On the server side, handlers can read the incoming trace context from `ctx.mcpRe When using SSE-based streaming, the server can assign event IDs. Pass `onresumptiontoken` to track them, and `resumptionToken` to resume from where you left off after a disconnection: -```ts source="../examples/client/src/clientGuide.examples.ts#resumptionToken_basic" +```ts source="../examples/guides/clientGuide.examples.ts#resumptionToken_basic" let lastToken: string | undefined; const result = await client.request( @@ -657,21 +839,21 @@ const result = await client.request( console.log(result); ``` -For an end-to-end example of server-initiated SSE disconnection and automatic client reconnection with event replay, see [`ssePollingClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/ssePollingClient.ts). +For an end-to-end example of server-initiated SSE disconnection and automatic client reconnection with event replay, see [`sse-polling/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sse-polling/client.ts). ## See also -- [`examples/client/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/client) — Full runnable client examples +- [`examples/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) — Full runnable client examples - [Server guide](./server.md) — Building MCP servers with this SDK - [MCP overview](https://modelcontextprotocol.io/docs/learn/architecture) — Protocol-level concepts: participants, layers, primitives -- [Migration guide](./migration.md) — Upgrading from previous SDK versions +- [Migration guide](./migration/index.md) — Upgrading from previous SDK versions - [FAQ](./faq.md) — Frequently asked questions and troubleshooting ### Additional examples -| Feature | Description | Example | -|---------|-------------|---------| -| Parallel tool calls | Run multiple tool calls concurrently via `Promise.all` | [`parallelToolCallsClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/parallelToolCallsClient.ts) | -| SSE disconnect / reconnection | Server-initiated SSE disconnect with automatic reconnection and event replay | [`ssePollingClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/ssePollingClient.ts) | -| Multiple clients | Independent client lifecycles to the same server | [`multipleClientsParallel.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/multipleClientsParallel.ts) | -| URL elicitation | Handle sensitive data collection via browser | [`elicitationUrlExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/elicitationUrlExample.ts) | +| Feature | Description | Example | +| ----------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| Parallel tool calls | Run multiple tool calls concurrently via `Promise.all` | [`parallel-calls/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/parallel-calls/client.ts) | +| SSE disconnect / reconnection | Server-initiated SSE disconnect with automatic reconnection and event replay | [`sse-polling/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sse-polling/client.ts) | +| Multiple clients | Independent client lifecycles to the same server | [`parallel-calls/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/parallel-calls/client.ts) | +| URL elicitation | Handle sensitive data collection via browser | [`elicitation/client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/elicitation/client.ts) | diff --git a/docs/faq.md b/docs/faq.md index 5bc9d71c00..66f3d46c04 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -67,7 +67,7 @@ For production use, you can either: ### Where can I find runnable server examples? -The [server quickstart](./server-quickstart.md) walks you through building a weather server from scratch. Its complete source lives in [`examples/server-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server-quickstart/). For more advanced examples (OAuth, streaming, sessions, etc.), see the server examples index in [`examples/server/README.md`](../examples/server/README.md). +The [server quickstart](./server-quickstart.md) walks you through building a weather server from scratch. Its complete source lives in [`examples/server-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server-quickstart/). For more advanced examples (OAuth, streaming, sessions, etc.), see the server examples index in [`examples/README.md`](../examples/README.md). ### Where are the server auth helpers? diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md deleted file mode 100644 index aef327622c..0000000000 --- a/docs/migration-SKILL.md +++ /dev/null @@ -1,561 +0,0 @@ ---- -name: migrate-v1-to-v2 -description: Migrate MCP TypeScript SDK code from v1 (@modelcontextprotocol/sdk) to v2 (@modelcontextprotocol/core, /client, /server). Use when a user asks to migrate, upgrade, or port their MCP TypeScript code from v1 to v2. ---- - -# MCP TypeScript SDK: v1 → v2 Migration - -Apply these changes in order: dependencies → imports → API calls → type aliases. - -## 1. Environment - -- Node.js 20+ required (v18 dropped) -- ESM only (CJS dropped). If the project uses `require()`, convert to `import`/`export` or use dynamic `import()`. - -## 2. Dependencies - -Remove the old package and install only what you need: - -```bash -npm uninstall @modelcontextprotocol/sdk -``` - -| You need | Install | -| --------------------- | ------------------------------------------------------------------------ | -| Client only | `npm install @modelcontextprotocol/client` | -| Server only | `npm install @modelcontextprotocol/server` | -| Server + Node.js HTTP | `npm install @modelcontextprotocol/server @modelcontextprotocol/node` | -| Server + Express | `npm install @modelcontextprotocol/server @modelcontextprotocol/express` | -| Server + Hono | `npm install @modelcontextprotocol/server @modelcontextprotocol/hono` | - -`@modelcontextprotocol/core` is installed automatically as a dependency. - -## 3. Import Mapping - -Replace all `@modelcontextprotocol/sdk/...` imports using this table. - -### Client imports - -| v1 import path | v2 package | -| ---------------------------------------------------- | ------------------------------------------------------------------------------ | -| `@modelcontextprotocol/sdk/client/index.js` | `@modelcontextprotocol/client` | -| `@modelcontextprotocol/sdk/client/auth.js` | `@modelcontextprotocol/client` | -| `@modelcontextprotocol/sdk/client/streamableHttp.js` | `@modelcontextprotocol/client` | -| `@modelcontextprotocol/sdk/client/sse.js` | `@modelcontextprotocol/client` | -| `@modelcontextprotocol/sdk/client/stdio.js` | `@modelcontextprotocol/client/stdio` | -| `@modelcontextprotocol/sdk/client/websocket.js` | REMOVED (use Streamable HTTP or stdio; implement `Transport` for custom needs) | - -### Server imports - -| v1 import path | v2 package | -| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@modelcontextprotocol/sdk/server/mcp.js` | `@modelcontextprotocol/server` | -| `@modelcontextprotocol/sdk/server/index.js` | `@modelcontextprotocol/server` | -| `@modelcontextprotocol/sdk/server/stdio.js` | `@modelcontextprotocol/server/stdio` | -| `@modelcontextprotocol/sdk/server/streamableHttp.js` | `@modelcontextprotocol/node` (class renamed to `NodeStreamableHTTPServerTransport`) OR `@modelcontextprotocol/server` (web-standard `WebStandardStreamableHTTPServerTransport` for Cloudflare Workers, Deno, etc.) | -| `@modelcontextprotocol/sdk/server/sse.js` | REMOVED (migrate to Streamable HTTP); legacy bridge: `@modelcontextprotocol/server-legacy/sse` | -| `@modelcontextprotocol/sdk/server/auth/*` | RS helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `OAuthTokenVerifier`) → `@modelcontextprotocol/express`; AS helpers (`mcpAuthRouter`, `OAuthServerProvider`, etc.) → `@modelcontextprotocol/server-legacy/auth` (deprecated); migrate AS to an external IdP/OAuth library | -| `@modelcontextprotocol/sdk/server/middleware.js` | `@modelcontextprotocol/express` (signature changed, see section 8) | - -### Types / shared imports - -| v1 import path | v2 package | -| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@modelcontextprotocol/sdk/types.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | -| `@modelcontextprotocol/sdk/shared/protocol.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | -| `@modelcontextprotocol/sdk/shared/transport.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | -| `@modelcontextprotocol/sdk/shared/uriTemplate.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | -| `@modelcontextprotocol/sdk/shared/auth.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | -| `@modelcontextprotocol/sdk/shared/stdio.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` (`ReadBuffer`, `serializeMessage`, `deserializeMessage` are in the root barrel; the `./stdio` subpath only has the transport class) | - -Notes: - -- `@modelcontextprotocol/client` and `@modelcontextprotocol/server` both re-export shared types from `@modelcontextprotocol/core`, so import from whichever package you already depend on. Do not import from `@modelcontextprotocol/core` directly — it is an internal package. -- When multiple v1 imports map to the same v2 package, consolidate them into a single import statement. - -## 4. Renamed Symbols - -| v1 symbol | v2 symbol | v2 package | -| ------------------------------- | ----------------------------------- | ---------------------------- | -| `StreamableHTTPServerTransport` | `NodeStreamableHTTPServerTransport` | `@modelcontextprotocol/node` | - -## 5. Removed / Renamed Type Aliases and Symbols - -| v1 (removed) | v2 (replacement) | -| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| `JSONRPCError` | `JSONRPCErrorResponse` | -| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | -| `isJSONRPCError` | `isJSONRPCErrorResponse` | -| `isJSONRPCResponse` (deprecated in v1) | `isJSONRPCResultResponse` (**not** v2's new `isJSONRPCResponse`, which correctly matches both result and error) | -| `ResourceReference` | `ResourceTemplateReference` | -| `ResourceReferenceSchema` | `ResourceTemplateReferenceSchema` | -| `IsomorphicHeaders` | REMOVED (use Web Standard `Headers`) | -| `AuthInfo` (from `server/auth/types.js`) | `AuthInfo` (now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`) | -| `McpError` | `ProtocolError` | -| `ErrorCode` | `ProtocolErrorCode` | -| `ErrorCode.RequestTimeout` | `SdkErrorCode.RequestTimeout` | -| `ErrorCode.ConnectionClosed` | `SdkErrorCode.ConnectionClosed` | -| `StreamableHTTPError` | REMOVED (use `SdkHttpError` with `SdkErrorCode.ClientHttp*`) | -| `WebSocketClientTransport` | REMOVED (use `StreamableHTTPClientTransport` or `StdioClientTransport`) | - -All other **type** symbols from `@modelcontextprotocol/sdk/types.js` retain their original names. **Zod schemas** (e.g., `CallToolResultSchema`, `ListToolsResultSchema`) are no longer part of the public API — they are internal to the SDK. For runtime validation, use -`isSpecType.TypeName(value)` (e.g., `isSpecType.CallToolResult(v)`) or `specTypeSchemas.TypeName` for the `StandardSchemaV1Sync` validator object. The keys are typed as `SpecTypeName`, a literal union of all spec type names. - -### Error class changes - -Three error classes now exist: - -- **`ProtocolError`** (renamed from `McpError`): Protocol errors that cross the wire as JSON-RPC responses -- **`SdkError`** (new): Local SDK errors that never cross the wire -- **`SdkHttpError`** (extends `SdkError`): HTTP transport errors with typed `.status` and `.statusText` accessors - -| Error scenario | v1 type | v2 type | -| --------------------------------- | -------------------------------------------- | --------------------------------------------------------------------- | -| Request timeout | `McpError` with `ErrorCode.RequestTimeout` | `SdkError` with `SdkErrorCode.RequestTimeout` | -| Connection closed | `McpError` with `ErrorCode.ConnectionClosed` | `SdkError` with `SdkErrorCode.ConnectionClosed` | -| Capability not supported | `new Error(...)` | `SdkError` with `SdkErrorCode.CapabilityNotSupported` | -| Not connected | `new Error('Not connected')` | `SdkError` with `SdkErrorCode.NotConnected` | -| Invalid params (server response) | `McpError` with `ErrorCode.InvalidParams` | `ProtocolError` with `ProtocolErrorCode.InvalidParams` | -| HTTP transport error | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttp*` | -| Failed to open SSE stream | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToOpenStream` | -| 401 after re-auth (circuit break) | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpAuthentication` | -| 403 after upscoping | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpForbidden` | -| Unexpected content type | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpUnexpectedContent` | -| Session termination failed | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToTerminateSession` | -| Response result fails schema | `ZodError` (raw) | `SdkError` with `SdkErrorCode.InvalidResult` | - -New `SdkErrorCode` enum values: - -- `SdkErrorCode.NotConnected` = `'NOT_CONNECTED'` -- `SdkErrorCode.AlreadyConnected` = `'ALREADY_CONNECTED'` -- `SdkErrorCode.NotInitialized` = `'NOT_INITIALIZED'` -- `SdkErrorCode.CapabilityNotSupported` = `'CAPABILITY_NOT_SUPPORTED'` -- `SdkErrorCode.RequestTimeout` = `'REQUEST_TIMEOUT'` -- `SdkErrorCode.ConnectionClosed` = `'CONNECTION_CLOSED'` -- `SdkErrorCode.SendFailed` = `'SEND_FAILED'` -- `SdkErrorCode.InvalidResult` = `'INVALID_RESULT'` -- `SdkErrorCode.ClientHttpNotImplemented` = `'CLIENT_HTTP_NOT_IMPLEMENTED'` -- `SdkErrorCode.ClientHttpAuthentication` = `'CLIENT_HTTP_AUTHENTICATION'` -- `SdkErrorCode.ClientHttpForbidden` = `'CLIENT_HTTP_FORBIDDEN'` -- `SdkErrorCode.ClientHttpUnexpectedContent` = `'CLIENT_HTTP_UNEXPECTED_CONTENT'` -- `SdkErrorCode.ClientHttpFailedToOpenStream` = `'CLIENT_HTTP_FAILED_TO_OPEN_STREAM'` -- `SdkErrorCode.ClientHttpFailedToTerminateSession` = `'CLIENT_HTTP_FAILED_TO_TERMINATE_SESSION'` - -Update error handling: - -```typescript -// v1 -if (error instanceof McpError && error.code === ErrorCode.RequestTimeout) { ... } - -// v2 -import { SdkError, SdkErrorCode } from '@modelcontextprotocol/client'; -if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) { ... } -``` - -Update HTTP transport error handling: - -```typescript -// v1 -import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -if (error instanceof StreamableHTTPError) { - console.log('HTTP status:', error.code); -} - -// v2 -import { SdkHttpError, SdkErrorCode } from '@modelcontextprotocol/client'; -if (error instanceof SdkHttpError) { - console.log('HTTP status:', error.status); // number — typed accessor - console.log('Status text:', error.statusText); // string | undefined - switch (error.code) { - case SdkErrorCode.ClientHttpAuthentication: // 401 after re-auth - case SdkErrorCode.ClientHttpForbidden: // 403 after upscoping - case SdkErrorCode.ClientHttpFailedToOpenStream: - case SdkErrorCode.ClientHttpNotImplemented: - break; - } -} -``` - -### OAuth error consolidation - -Individual OAuth error classes replaced with single `OAuthError` class and `OAuthErrorCode` enum: - -| v1 Class | v2 Equivalent | -| ------------------------------ | ---------------------------------------------------------- | -| `InvalidRequestError` | `OAuthError` with `OAuthErrorCode.InvalidRequest` | -| `InvalidClientError` | `OAuthError` with `OAuthErrorCode.InvalidClient` | -| `InvalidGrantError` | `OAuthError` with `OAuthErrorCode.InvalidGrant` | -| `UnauthorizedClientError` | `OAuthError` with `OAuthErrorCode.UnauthorizedClient` | -| `UnsupportedGrantTypeError` | `OAuthError` with `OAuthErrorCode.UnsupportedGrantType` | -| `InvalidScopeError` | `OAuthError` with `OAuthErrorCode.InvalidScope` | -| `AccessDeniedError` | `OAuthError` with `OAuthErrorCode.AccessDenied` | -| `ServerError` | `OAuthError` with `OAuthErrorCode.ServerError` | -| `TemporarilyUnavailableError` | `OAuthError` with `OAuthErrorCode.TemporarilyUnavailable` | -| `UnsupportedResponseTypeError` | `OAuthError` with `OAuthErrorCode.UnsupportedResponseType` | -| `UnsupportedTokenTypeError` | `OAuthError` with `OAuthErrorCode.UnsupportedTokenType` | -| `InvalidTokenError` | `OAuthError` with `OAuthErrorCode.InvalidToken` | -| `MethodNotAllowedError` | `OAuthError` with `OAuthErrorCode.MethodNotAllowed` | -| `TooManyRequestsError` | `OAuthError` with `OAuthErrorCode.TooManyRequests` | -| `InvalidClientMetadataError` | `OAuthError` with `OAuthErrorCode.InvalidClientMetadata` | -| `InsufficientScopeError` | `OAuthError` with `OAuthErrorCode.InsufficientScope` | -| `InvalidTargetError` | `OAuthError` with `OAuthErrorCode.InvalidTarget` | -| `CustomOAuthError` | `new OAuthError(customCode, message)` | - -Removed: `OAUTH_ERRORS` constant. - -Update OAuth error handling: - -```typescript -// v1 -import { InvalidClientError, InvalidGrantError } from '@modelcontextprotocol/client'; -if (error instanceof InvalidClientError) { ... } - -// v2 -import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/client'; -if (error instanceof OAuthError && error.code === OAuthErrorCode.InvalidClient) { ... } -``` - -**Unchanged APIs** (only import paths changed): `Client` constructor and most methods, `McpServer` constructor, `server.connect()`, `server.close()`, all client transports (`StreamableHTTPClientTransport`, `SSEClientTransport`, `StdioClientTransport`), `StdioServerTransport`, all -Zod schemas, all callback return types. Note: `callTool()` and `request()` signatures changed (schema parameter removed, see section 11). - -## 6. McpServer API Changes - -The variadic `.tool()`, `.prompt()`, `.resource()` methods are removed. Use the `register*` methods with a config object. - -**IMPORTANT**: v2 requires schema objects implementing [Standard Schema](https://standardschema.dev/) — raw shapes like `{ name: z.string() }` are no longer supported. Wrap with `z.object()` (Zod v4), or use ArkType's `type({...})`, or Valibot. For raw JSON Schema, wrap with -`fromJsonSchema(schema)` from `@modelcontextprotocol/server` (validator defaults automatically; pass an explicit validator for custom configurations). Applies to `inputSchema`, `outputSchema`, and `argsSchema`. - -### Tools - -```typescript -// v1: server.tool(name, schema, callback) - raw shape worked -server.tool('greet', { name: z.string() }, async ({ name }) => { - return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; -}); - -// v1: server.tool(name, description, schema, callback) -server.tool('greet', 'Greet a user', { name: z.string() }, async ({ name }) => { - return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; -}); - -// v2: server.registerTool(name, config, callback) -server.registerTool( - 'greet', - { - description: 'Greet a user', - inputSchema: z.object({ name: z.string() }) - }, - async ({ name }) => { - return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; - } -); -``` - -Config object fields: `title?`, `description?`, `inputSchema?`, `outputSchema?`, `annotations?`, `_meta?` - -### Prompts - -```typescript -// v1: server.prompt(name, schema, callback) - raw shape worked -server.prompt('summarize', { text: z.string() }, async ({ text }) => { - return { messages: [{ role: 'user', content: { type: 'text', text } }] }; -}); - -// v2: server.registerPrompt(name, config, callback) -server.registerPrompt( - 'summarize', - { - argsSchema: z.object({ text: z.string() }) - }, - async ({ text }) => { - return { messages: [{ role: 'user', content: { type: 'text', text } }] }; - } -); -``` - -Config object fields: `title?`, `description?`, `argsSchema?` - -### Resources - -```typescript -// v1: server.resource(name, uri, callback) -server.resource('config', 'config://app', async uri => { - return { contents: [{ uri: uri.href, text: '{}' }] }; -}); - -// v2: server.registerResource(name, uri, metadata, callback) -server.registerResource('config', 'config://app', {}, async uri => { - return { contents: [{ uri: uri.href, text: '{}' }] }; -}); -``` - -Note: the third argument (`metadata`) is required — pass `{}` if no metadata. - -### Schema Migration Quick Reference - -| v1 (raw shape) | v2 (Standard Schema object) | -| ---------------------------------- | -------------------------------------------- | -| `{ name: z.string() }` | `z.object({ name: z.string() })` | -| `{ count: z.number().optional() }` | `z.object({ count: z.number().optional() })` | -| `{}` (empty) | `z.object({})` | -| `undefined` (no schema) | `undefined` or omit the field | - -### Removed core exports - -| Removed from `@modelcontextprotocol/core` | Replacement | -| ------------------------------------------------------------------------------------ | ----------------------------------------- | -| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` | -| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` | -| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | -| `getSchemaShape`, `getSchemaDescription`, `isOptionalSchema`, `unwrapOptionalSchema` | none (internal Zod introspection helpers) | - -## 7. Headers API - -Transport constructors now use the Web Standard `Headers` object instead of plain objects. The custom `RequestInfo` type has been replaced with the standard Web `Request` object, giving access to headers, URL, query parameters, and method. - -```typescript -// v1: plain object, bracket access, custom RequestInfo -headers: { 'Authorization': 'Bearer token' } -extra.requestInfo?.headers['mcp-session-id'] - -// v2: Headers object, .get() access, standard Web Request -headers: new Headers({ 'Authorization': 'Bearer token' }) -ctx.http?.req?.headers.get('mcp-session-id') -new URL(ctx.http?.req?.url).searchParams.get('debug') -``` - -## 8. Removed Server Features - -### SSE server transport - -`SSEServerTransport` removed entirely. Migrate to `NodeStreamableHTTPServerTransport` (from `@modelcontextprotocol/node`). Client-side `SSEClientTransport` still available for connecting to legacy servers. Legacy bridge: -`import { SSEServerTransport } from '@modelcontextprotocol/server-legacy/sse'` (deprecated, frozen v1 copy). - -### Server-side auth - -Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`) are first-class in `@modelcontextprotocol/express`. Authorization Server helpers (`mcpAuthRouter`, `OAuthServerProvider`, -`ProxyOAuthServerProvider`, `authenticateClient`, `allowedMethods`, etc.) are available from `@modelcontextprotocol/server-legacy/auth` (deprecated, frozen v1 copy). Migrate AS to an external IdP/OAuth library for production use. See `examples/server/src/` for demos. - -### Host header validation (Express) - -`hostHeaderValidation()` and `localhostHostValidation()` moved from server package to `@modelcontextprotocol/express`. Signature changed: takes `string[]` instead of options object. - -```typescript -// v1 -import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware.js'; -app.use(hostHeaderValidation({ allowedHosts: ['example.com'] })); - -// v2 -import { hostHeaderValidation } from '@modelcontextprotocol/express'; -app.use(hostHeaderValidation(['example.com'])); -``` - -The server package now exports framework-agnostic alternatives: `validateHostHeader()`, `localhostAllowedHostnames()`, `hostHeaderValidationResponse()`. - -## 9. `setRequestHandler` / `setNotificationHandler` API - -The low-level handler registration methods now take a method string instead of a Zod schema. - -```typescript -// v1: schema-based -server.setRequestHandler(InitializeRequestSchema, async (request) => { ... }); -server.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { ... }); - -// v2: method string -server.setRequestHandler('initialize', async (request) => { ... }); -server.setNotificationHandler('notifications/message', (notification) => { ... }); -``` - -For custom (non-spec) methods, use the 3-arg form `(method, schemas, handler)`: - -```typescript -// v1: Zod schema with method literal -server.setRequestHandler(z.object({ method: z.literal('acme/search'), params: P }), async req => { ... }); - -// v2: method string + schemas object; handler receives parsed params -server.setRequestHandler('acme/search', { params: P, result: R }, async (params, ctx) => { ... }); -client.setNotificationHandler('acme/progress', { params: P }, (params, notification) => { ... }); -``` - -The 3-arg notification handler receives the raw notification as its second argument, so `_meta` is recoverable via `notification.params?._meta`. - -To send a custom-method request, pass a result schema as the second argument to `request()` (and `ctx.mcpReq.send()`): - -```typescript -// v1 -await client.request({ method: 'acme/search', params }, ResultSchema); -// v2 (unchanged; now any Standard Schema, not Zod-only) -await client.request({ method: 'acme/search', params }, ResultSchema); -``` - -Schema to method string mapping: - -| v1 Schema | v2 Method String | -| --------------------------------------- | ---------------------------------------- | -| `InitializeRequestSchema` | `'initialize'` | -| `CallToolRequestSchema` | `'tools/call'` | -| `ListToolsRequestSchema` | `'tools/list'` | -| `ListPromptsRequestSchema` | `'prompts/list'` | -| `GetPromptRequestSchema` | `'prompts/get'` | -| `ListResourcesRequestSchema` | `'resources/list'` | -| `ReadResourceRequestSchema` | `'resources/read'` | -| `CreateMessageRequestSchema` | `'sampling/createMessage'` | -| `ElicitRequestSchema` | `'elicitation/create'` | -| `SetLevelRequestSchema` | `'logging/setLevel'` | -| `PingRequestSchema` | `'ping'` | -| `LoggingMessageNotificationSchema` | `'notifications/message'` | -| `ToolListChangedNotificationSchema` | `'notifications/tools/list_changed'` | -| `ResourceListChangedNotificationSchema` | `'notifications/resources/list_changed'` | -| `PromptListChangedNotificationSchema` | `'notifications/prompts/list_changed'` | -| `ProgressNotificationSchema` | `'notifications/progress'` | -| `CancelledNotificationSchema` | `'notifications/cancelled'` | -| `InitializedNotificationSchema` | `'notifications/initialized'` | - -Request/notification params remain fully typed. Remove unused schema imports after migration. - -## 10. Request Handler Context Types - -`RequestHandlerExtra` → structured context types with nested groups. Rename `extra` → `ctx` in all handler callbacks. - -| v1 | v2 | -| ------------------------------------------------- | -------------------------------------------------------------------------- | -| `RequestHandlerExtra` | `ServerContext` (server) / `ClientContext` (client) / `BaseContext` (base) | -| `extra` (param name) | `ctx` | -| `extra.signal` | `ctx.mcpReq.signal` | -| `extra.requestId` | `ctx.mcpReq.id` | -| `extra._meta` | `ctx.mcpReq._meta` | -| `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` | -| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` | -| `extra.authInfo` | `ctx.http?.authInfo` | -| `extra.sessionId` | `ctx.sessionId` | -| `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only `ServerContext`) | -| `extra.closeSSEStream` | `ctx.http?.closeSSE` (only `ServerContext`) | -| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only `ServerContext`) | -| `extra.taskStore` / `taskId` / `taskRequestedTtl` | _removed; see §12_ | - -`ServerContext` convenience methods (new in v2, no v1 equivalent): - -| Method | Description | Replaces | -| ---------------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------- | -| `ctx.mcpReq.log(level, data, logger?)` | Send log notification (respects client's level filter) | `server.sendLoggingMessage(...)` from within handler | -| `ctx.mcpReq.elicitInput(params, options?)` | Elicit user input (form or URL) | `server.elicitInput(...)` from within handler | -| `ctx.mcpReq.requestSampling(params, options?)` | Request LLM sampling from client | `server.createMessage(...)` from within handler | - -## 11. Schema parameter removed from `request()`, `send()`, and `callTool()` (spec methods) - -For **spec** methods, `Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` no longer require a Zod result schema argument. The SDK resolves the schema internally from the method name. - -```typescript -// v1: schema required -import { CallToolResultSchema, ElicitResultSchema } from '@modelcontextprotocol/sdk/types.js'; -const result = await client.request({ method: 'tools/call', params: { ... } }, CallToolResultSchema); -const elicit = await ctx.mcpReq.send({ method: 'elicitation/create', params: { ... } }, ElicitResultSchema); -const tool = await client.callTool({ name: 'my-tool', arguments: {} }, CompatibilityCallToolResultSchema); - -// v2: no schema argument -const result = await client.request({ method: 'tools/call', params: { ... } }); -const elicit = await ctx.mcpReq.send({ method: 'elicitation/create', params: { ... } }); -const tool = await client.callTool({ name: 'my-tool', arguments: {} }); -``` - -| v1 call | v2 call | -| ------------------------------------------------------------ | ---------------------------------- | -| `client.request(req, ResultSchema)` | `client.request(req)` | -| `client.request(req, ResultSchema, options)` | `client.request(req, options)` | -| `ctx.mcpReq.send(req, ResultSchema)` | `ctx.mcpReq.send(req)` | -| `ctx.mcpReq.send(req, ResultSchema, options)` | `ctx.mcpReq.send(req, options)` | -| `client.callTool(params, CompatibilityCallToolResultSchema)` | `client.callTool(params)` | -| `client.callTool(params, schema, options)` | `client.callTool(params, options)` | - -For **custom (non-spec)** methods, keep the result-schema argument — see §9. Only apply the rewrites above when `req.method` is a spec method. - -Remove unused schema imports: `CallToolResultSchema`, `CompatibilityCallToolResultSchema`, `ElicitResultSchema`, `CreateMessageResultSchema`, etc., when they were only used in `request()`/`send()`/`callTool()` calls. - -If a `*Schema` constant was used for **runtime validation** (not just as a `request()` argument), replace with `isSpecType` / `specTypeSchemas`: - -| v1 pattern | v2 replacement | -| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `CallToolResultSchema.safeParse(value).success` | `isSpecType.CallToolResult(value)` | -| `Schema.safeParse(value).success` | `isSpecType.(value)` | -| `Schema.parse(value)` | `specTypeSchemas.['~standard'].validate(value)` (returns a `Result` synchronously, not the value) | -| Passing `Schema` as a validator argument | `specTypeSchemas.` (a `StandardSchemaV1Sync`) | - -`isCallToolResult(value)` still works, but `isSpecType` covers every spec type by name. - -## 12. Experimental tasks interception removed - -The 2025-11 task side-channel through `Protocol` is removed (was always `@experimental`). No mechanical migration; remove usages. - -| Removed | Notes | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | -| `ProtocolOptions.tasks` | drop the option | -| `protocol.taskManager` | gone | -| `RequestOptions.task` / `.relatedTask`, `NotificationOptions.relatedTask` | drop the option | -| `BaseContext.task` (`ctx.task?.*`) | gone | -| `assertTaskCapability` / `assertTaskHandlerCapability` overrides | delete the override | -| `*.experimental.tasks.*` accessors, `Experimental{Client,Server,McpServer}Tasks` | removed | -| `requestStream` / `callToolStream` / `createMessageStream` / `elicitInputStream` | removed; no streaming variant | -| `registerToolTask`, `ToolTaskHandler`, `TaskRequestHandler`, `CreateTaskRequestHandler` | removed | -| `TaskMessageQueue`, `InMemoryTaskMessageQueue`, `BaseQueuedMessage`, `Queued*`, `CreateTaskServerContext`, `TaskServerContext`, `TaskToolExecution` | removed | -| `ResponseMessage`, `BaseResponseMessage`, `ErrorMessage`, `AsyncGeneratorValue`, `TaskStatusMessage`, `TaskCreatedMessage`, `ResultMessage`, `takeResult`, `toArrayAsync` | removed | - -`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`. - -## 13. Behavioral Changes - -### Client - -`Client.listPrompts()`, `listResources()`, `listResourceTemplates()`, `listTools()` now return empty results when the server lacks the corresponding capability (instead of sending the request). Set `enforceStrictCapabilities: true` in `ClientOptions` to throw an error instead. - -### Server (Streamable HTTP transport) - -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. - -## 14. Runtime-Specific JSON Schema Validators (Enhancement) - -The SDK now auto-selects the appropriate JSON Schema validator based on runtime: - -- Node.js → AJV (no change from v1) -- Cloudflare Workers (workerd) → `@cfworker/json-schema` (previously required manual config) - -**No action required** for most users. Cloudflare Workers users can remove explicit `jsonSchemaValidator` configuration: - -```typescript -// v1 (Cloudflare Workers): Required explicit validator -new McpServer( - { name: 'server', version: '1.0.0' }, - { - jsonSchemaValidator: new CfWorkerJsonSchemaValidator() - } -); - -// v2 (Cloudflare Workers): Auto-selected, explicit config optional -new McpServer({ name: 'server', version: '1.0.0' }, {}); -``` - -Validator behavior: - -- Do not add validator imports for normal migrations. -- Do not install `ajv`, `ajv-formats`, or `@cfworker/json-schema` for the default path; client/server bundle the runtime-selected defaults and the root entry point does not pull either dep in. -- To customize the built-in backend (e.g. register custom AJV formats, change `@cfworker/json-schema` draft), import the named class from the package subpath: `@modelcontextprotocol/{client,server}/validators/ajv` for `AjvJsonSchemaValidator`, - `@modelcontextprotocol/{client,server}/validators/cf-worker` for `CfWorkerJsonSchemaValidator`. Importing from a subpath means the corresponding peer dep must be in your `package.json`. -- To replace validation entirely, pass `jsonSchemaValidator: myCustomValidator` with your own implementation of the `jsonSchemaValidator` interface. - -## 15. Migration Steps (apply in this order) - -1. Update `package.json`: `npm uninstall @modelcontextprotocol/sdk`, install the appropriate v2 packages -2. Replace all imports from `@modelcontextprotocol/sdk/...` using the import mapping tables (sections 3-4), including `StreamableHTTPServerTransport` → `NodeStreamableHTTPServerTransport` -3. Replace removed type aliases (`JSONRPCError` → `JSONRPCErrorResponse`, etc.) per section 5 -4. Replace `.tool()` / `.prompt()` / `.resource()` calls with `registerTool` / `registerPrompt` / `registerResource` per section 6 -5. **Wrap all raw Zod shapes with `z.object()`**: Change `inputSchema: { name: z.string() }` → `inputSchema: z.object({ name: z.string() })`. Same for `outputSchema` in tools and `argsSchema` in prompts. -6. Replace plain header objects with `new Headers({...})` and bracket access (`headers['x']`) with `.get()` calls per section 7 -7. If using `hostHeaderValidation` from server, update import and signature per section 8 -8. If using server SSE transport, migrate to Streamable HTTP -9. If using server auth from the SDK: RS helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `OAuthTokenVerifier`) → `@modelcontextprotocol/express`; AS helpers → `@modelcontextprotocol/server-legacy/auth` (deprecated); migrate AS to external IdP/OAuth library -10. If relying on `listTools()`/`listPrompts()`/etc. throwing on missing capabilities, set `enforceStrictCapabilities: true` -11. Verify: build with `tsc` / run tests diff --git a/docs/migration.md b/docs/migration.md deleted file mode 100644 index 576f6c5ce4..0000000000 --- a/docs/migration.md +++ /dev/null @@ -1,1015 +0,0 @@ -# Migration Guide: v1 to v2 - -This guide covers the breaking changes introduced in v2 of the MCP TypeScript SDK and how to update your code. - -## Overview - -Version 2 of the MCP TypeScript SDK introduces several breaking changes to improve modularity, reduce dependency bloat, and provide a cleaner API surface. The biggest change is the split from a single `@modelcontextprotocol/sdk` package into separate `@modelcontextprotocol/core`, -`@modelcontextprotocol/client`, and `@modelcontextprotocol/server` packages. - -## Breaking Changes - -### Package split (monorepo) - -The single `@modelcontextprotocol/sdk` package has been split into three packages: - -| v1 | v2 | -| --------------------------- | ---------------------------------------------------------- | -| `@modelcontextprotocol/sdk` | `@modelcontextprotocol/core` (types, protocol, transports) | -| | `@modelcontextprotocol/client` (client implementation) | -| | `@modelcontextprotocol/server` (server implementation) | - -Remove the old package and install only the packages you need: - -```bash -npm uninstall @modelcontextprotocol/sdk - -# If you only need a client -npm install @modelcontextprotocol/client - -# If you only need a server -npm install @modelcontextprotocol/server - -# Both packages depend on @modelcontextprotocol/core automatically -``` - -Update your imports accordingly: - -**Before (v1):** - -```typescript -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -``` - -**After (v2):** - -```typescript -import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; - -// Node.js HTTP server transport is in the @modelcontextprotocol/node package -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -``` - -Note: `@modelcontextprotocol/client` and `@modelcontextprotocol/server` both re-export shared types from `@modelcontextprotocol/core`, so you can import types and error classes from whichever package you already depend on. Do not import from `@modelcontextprotocol/core` directly -— it is an internal package. - -### Dropped Node.js 18 and CommonJS - -v2 requires **Node.js 20+** and ships **ESM only** (no more CommonJS builds). - -If your project uses CommonJS (`require()`), you will need to either: - -- Migrate to ESM (`import`/`export`) -- Use dynamic `import()` to load the SDK - -### Server decoupled from HTTP frameworks - -The server package no longer depends on Express or Hono. HTTP framework integrations are now separate middleware packages: - -| v1 | v2 | -| -------------------------------------- | ------------------------------------------- | -| Built into `@modelcontextprotocol/sdk` | `@modelcontextprotocol/node` (Node.js HTTP) | -| | `@modelcontextprotocol/express` (Express) | -| | `@modelcontextprotocol/hono` (Hono) | - -Install the middleware package for your framework: - -```bash -npm install @modelcontextprotocol/node # Node.js native http -npm install @modelcontextprotocol/express # Express -npm install @modelcontextprotocol/hono # Hono -``` - -### `StreamableHTTPServerTransport` renamed - -`StreamableHTTPServerTransport` has been renamed to `NodeStreamableHTTPServerTransport` and moved to `@modelcontextprotocol/node`. - -**Before (v1):** - -```typescript -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; - -const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); -``` - -**After (v2):** - -```typescript -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; - -const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); -``` - -### Server-side SSE transport removed - -The SSE transport has been removed from the server. Servers should migrate to Streamable HTTP. The client-side SSE transport remains available for connecting to legacy SSE servers. - -If you need a temporary bridge during migration, `@modelcontextprotocol/server-legacy/sse` provides a frozen copy of the v1 `SSEServerTransport`: - -```typescript -import { SSEServerTransport } from '@modelcontextprotocol/server-legacy/sse'; -``` - -This package is deprecated and will not receive new features. - -### `WebSocketClientTransport` removed - -`WebSocketClientTransport` has been removed. WebSocket is not a spec-defined MCP transport, and keeping it in the SDK encouraged transport proliferation without a conformance baseline. - -Use `StdioClientTransport` for local servers or `StreamableHTTPClientTransport` for remote servers. If you need WebSocket for a custom deployment, implement the `Transport` interface directly — it remains exported from `@modelcontextprotocol/client`. - -**Before (v1):** - -```typescript -import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'; -const transport = new WebSocketClientTransport(new URL('ws://localhost:3000')); -``` - -**After (v2):** - -```typescript -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp')); -``` - -### Server auth split - -Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`) are first-class in `@modelcontextprotocol/express`. - -Authorization Server helpers (`mcpAuthRouter`, `OAuthServerProvider`, `ProxyOAuthServerProvider`, `authenticateClient`, `allowedMethods`, etc.) have been removed from the core SDK; new code should use a dedicated IdP/OAuth library. See the [examples](../examples/server/src/) for -a working demo with `better-auth`. - -Note: `AuthInfo` has moved from `server/auth/types.ts` to the core types and is now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`. - -### `Headers` object instead of plain objects - -Transport APIs and `RequestInfo.headers` now use the Web Standard `Headers` object instead of plain `Record` (`IsomorphicHeaders` has been removed). - -This affects both transport constructors and request handler code that reads headers: - -**Before (v1):** - -```typescript -// Transport headers -const transport = new StreamableHTTPClientTransport(url, { - requestInit: { - headers: { - Authorization: 'Bearer token', - 'X-Custom': 'value' - } - } -}); - -// Reading headers in a request handler -const sessionId = extra.requestInfo?.headers['mcp-session-id']; -``` - -**After (v2):** - -```typescript -// Transport headers -const transport = new StreamableHTTPClientTransport(url, { - requestInit: { - headers: new Headers({ - Authorization: 'Bearer token', - 'X-Custom': 'value' - }) - } -}); - -// Reading headers in a request handler (ctx.http.req is the standard Web Request object) -const sessionId = ctx.http?.req?.headers.get('mcp-session-id'); - -// Reading query parameters -const url = new URL(ctx.http!.req!.url); -const debug = url.searchParams.get('debug'); -``` - -### `McpServer.tool()`, `.prompt()`, `.resource()` removed - -The deprecated variadic-overload methods have been removed. Use `registerTool`, `registerPrompt`, and `registerResource` instead. These use an explicit config object rather than positional arguments. - -**Before (v1):** - -```typescript -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; - -const server = new McpServer({ name: 'demo', version: '1.0.0' }); - -// Tool with schema -server.tool('greet', { name: z.string() }, async ({ name }) => { - return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; -}); - -// Tool with description -server.tool('greet', 'Greet a user', { name: z.string() }, async ({ name }) => { - return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; -}); - -// Prompt -server.prompt('summarize', { text: z.string() }, async ({ text }) => { - return { messages: [{ role: 'user', content: { type: 'text', text: `Summarize: ${text}` } }] }; -}); - -// Resource -server.resource('config', 'config://app', async uri => { - return { contents: [{ uri: uri.href, text: '{}' }] }; -}); -``` - -**After (v2):** - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import * as z from 'zod/v4'; - -const server = new McpServer({ name: 'demo', version: '1.0.0' }); - -// Tool with schema -server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, async ({ name }) => { - return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; -}); - -// Tool with description -server.registerTool('greet', { description: 'Greet a user', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => { - return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; -}); - -// Prompt -server.registerPrompt('summarize', { argsSchema: z.object({ text: z.string() }) }, async ({ text }) => { - return { messages: [{ role: 'user', content: { type: 'text', text: `Summarize: ${text}` } }] }; -}); - -// Resource -server.registerResource('config', 'config://app', {}, async uri => { - return { contents: [{ uri: uri.href, text: '{}' }] }; -}); -``` - -### Standard Schema objects required (raw shapes no longer supported) - -v2 requires schema objects implementing the [Standard Schema spec](https://standardschema.dev/) for `inputSchema`, `outputSchema`, and `argsSchema`. Raw object shapes are no longer accepted. Zod v4, ArkType, and Valibot all implement the spec. - -**Before (v1):** - -```typescript -// Raw shape (object with Zod fields) - worked in v1 -server.tool('greet', { name: z.string() }, async ({ name }) => { ... }); - -server.registerTool('greet', { - inputSchema: { name: z.string() } // raw shape -}, callback); -``` - -**After (v2):** - -```typescript -import * as z from 'zod/v4'; - -// Wrap with z.object() (or use any Standard Schema library) -server.registerTool('greet', { - inputSchema: z.object({ name: z.string() }) -}, async ({ name }) => { ... }); - -// ArkType works too -import { type } from 'arktype'; -server.registerTool('greet', { - inputSchema: type({ name: 'string' }) -}, async ({ name }) => { ... }); - -// Raw JSON Schema via fromJsonSchema (validator defaults to runtime-appropriate choice) -import { fromJsonSchema } from '@modelcontextprotocol/server'; -server.registerTool('greet', { - inputSchema: fromJsonSchema({ type: 'object', properties: { name: { type: 'string' } } }) -}, handler); - -// For tools with no parameters, use z.object({}) -server.registerTool('ping', { - inputSchema: z.object({}) -}, async () => { ... }); -``` - -This applies to: - -- `inputSchema` in `registerTool()` -- `outputSchema` in `registerTool()` -- `argsSchema` in `registerPrompt()` - -**Removed Zod-specific helpers** from `@modelcontextprotocol/core` (use Standard Schema equivalents): - -| Removed | Replacement | -| ------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | -| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` | -| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` | -| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | -| `getSchemaShape`, `getSchemaDescription`, `isOptionalSchema`, `unwrapOptionalSchema` | No replacement — these are now internal Zod introspection helpers | - -### Host header validation moved - -Express-specific middleware (`hostHeaderValidation()`, `localhostHostValidation()`) moved from the server package to `@modelcontextprotocol/express`. The server package now exports framework-agnostic functions instead: `validateHostHeader()`, `localhostAllowedHostnames()`, -`hostHeaderValidationResponse()`. - -**Before (v1):** - -```typescript -import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware.js'; -app.use(hostHeaderValidation({ allowedHosts: ['example.com'] })); -``` - -**After (v2):** - -```typescript -import { hostHeaderValidation } from '@modelcontextprotocol/express'; -app.use(hostHeaderValidation(['example.com'])); -``` - -Note: the v2 signature takes a plain `string[]` instead of an options object. - -### Resumability gating for unknown protocol versions (Streamable HTTP server) - -The server-side Streamable HTTP transport enables resumability behavior introduced with protocol version `2025-11-25` — SSE priming events and the `closeSSEStream` / `closeStandaloneSSEStream` callbacks — based on the client's protocol version. Previously this was an -open-ended `protocolVersion >= '2025-11-25'` comparison, so an unrecognized future version string in an `initialize` request body (which, unlike the `MCP-Protocol-Version` header, is not validated against the supported-versions list) silently enabled the behavior. - -The check is now bounded: the version must be one of the transport's supported protocol versions (after `connect()`, the server's `supportedProtocolVersions`) **and** at least `2025-11-25`. Behavior for all currently supported protocol versions (`2024-10-07` through -`2025-11-25`) is unchanged. Clients claiming an unknown future protocol version in the initialize body are now treated like clients without empty-SSE-data support: no priming event is sent and no early-close callbacks are provided. - -### `setRequestHandler` and `setNotificationHandler` use method strings - -The low-level `setRequestHandler` and `setNotificationHandler` methods on `Client`, `Server`, and `Protocol` now take a method string instead of a Zod schema. - -**Before (v1):** - -```typescript -import { Server, InitializeRequestSchema, LoggingMessageNotificationSchema } from '@modelcontextprotocol/sdk/server/index.js'; - -const server = new Server({ name: 'my-server', version: '1.0.0' }); - -// Request handler with schema -server.setRequestHandler(InitializeRequestSchema, async request => { - return { protocolVersion: '...', capabilities: {}, serverInfo: { name: '...', version: '...' } }; -}); - -// Notification handler with schema -server.setNotificationHandler(LoggingMessageNotificationSchema, notification => { - console.log(notification.params.data); -}); -``` - -**After (v2):** - -```typescript -import { Server } from '@modelcontextprotocol/server'; - -const server = new Server({ name: 'my-server', version: '1.0.0' }); - -// Request handler with method string -server.setRequestHandler('initialize', async request => { - return { protocolVersion: '...', capabilities: {}, serverInfo: { name: '...', version: '...' } }; -}); - -// Notification handler with method string -server.setNotificationHandler('notifications/message', notification => { - console.log(notification.params.data); -}); -``` - -The request and notification parameters remain fully typed via `RequestTypeMap` and `NotificationTypeMap`. You no longer need to import the individual `*RequestSchema` or `*NotificationSchema` constants for handler registration. - -#### Custom (non-spec) methods - -For vendor-prefixed methods (anything not in the MCP spec), use the 3-arg form: pass the method string, a `{ params, result? }` schemas object, and the handler. Any [Standard Schema](https://standardschema.dev) library works (Zod, Valibot, ArkType). - -**Before (v1):** - -```typescript -const AcmeSearch = z.object({ - method: z.literal('acme/search'), - params: z.object({ query: z.string(), limit: z.number().int() }) -}); -server.setRequestHandler(AcmeSearch, async request => { - return { - items: [ - /* ... */ - ] - }; -}); -``` - -**After (v2):** - -```typescript -const SearchParams = z.object({ query: z.string(), limit: z.number().int() }); -const SearchResult = z.object({ items: z.array(z.string()) }); - -server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => { - return { - items: [ - /* ... */ - ] - }; -}); -``` - -The handler receives the parsed `params` directly (not the full request envelope). `_meta` is stripped before validation and is available as `ctx.mcpReq._meta`. Supplying `result` types the handler's return value; omit it to return any `Result`. - -For `setNotificationHandler`, the 3-arg handler is `(params, notification) => void`. The raw notification is the second argument, so `_meta` is recoverable via `notification.params?._meta`. - -#### Sending custom-method requests - -`request()` and `ctx.mcpReq.send()` accept a result schema as the second argument; for custom methods this is required: - -```typescript -const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult); -result.items; // string[] -``` - -For spec methods the 1-arg form still works and the result type is inferred from the method name. - -Common method string replacements: - -| Schema (v1) | Method string (v2) | -| --------------------------------------- | ---------------------------------------- | -| `InitializeRequestSchema` | `'initialize'` | -| `CallToolRequestSchema` | `'tools/call'` | -| `ListToolsRequestSchema` | `'tools/list'` | -| `ListPromptsRequestSchema` | `'prompts/list'` | -| `GetPromptRequestSchema` | `'prompts/get'` | -| `ListResourcesRequestSchema` | `'resources/list'` | -| `ReadResourceRequestSchema` | `'resources/read'` | -| `CreateMessageRequestSchema` | `'sampling/createMessage'` | -| `ElicitRequestSchema` | `'elicitation/create'` | -| `LoggingMessageNotificationSchema` | `'notifications/message'` | -| `ToolListChangedNotificationSchema` | `'notifications/tools/list_changed'` | -| `ResourceListChangedNotificationSchema` | `'notifications/resources/list_changed'` | -| `PromptListChangedNotificationSchema` | `'notifications/prompts/list_changed'` | - -### `Protocol.request()`, `ctx.mcpReq.send()`, and `Client.callTool()` no longer require a schema parameter for spec methods - -For **spec** methods, the public `Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` methods no longer require a Zod result schema argument. The SDK now resolves the correct result schema internally based on the method name. This means you no longer need to -import result schemas like `CallToolResultSchema` or `ElicitResultSchema` when making spec-method requests. - -**`client.request()` — Before (v1):** - -```typescript -import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; - -const result = await client.request({ method: 'tools/call', params: { name: 'my-tool', arguments: {} } }, CallToolResultSchema); -``` - -**After (v2):** - -```typescript -const result = await client.request({ method: 'tools/call', params: { name: 'my-tool', arguments: {} } }); -``` - -**`ctx.mcpReq.send()` — Before (v1):** - -```typescript -import { CreateMessageResultSchema } from '@modelcontextprotocol/sdk/types.js'; - -server.setRequestHandler('tools/call', async (request, ctx) => { - const samplingResult = await ctx.mcpReq.send( - { method: 'sampling/createMessage', params: { messages: [...], maxTokens: 100 } }, - CreateMessageResultSchema - ); - return { content: [{ type: 'text', text: 'done' }] }; -}); -``` - -**After (v2):** - -```typescript -server.setRequestHandler('tools/call', async (request, ctx) => { - const samplingResult = await ctx.mcpReq.send( - { method: 'sampling/createMessage', params: { messages: [...], maxTokens: 100 } } - ); - return { content: [{ type: 'text', text: 'done' }] }; -}); -``` - -**`client.callTool()` — Before (v1):** - -```typescript -import { CompatibilityCallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; - -const result = await client.callTool({ name: 'my-tool', arguments: {} }, CompatibilityCallToolResultSchema); -``` - -**After (v2):** - -```typescript -const result = await client.callTool({ name: 'my-tool', arguments: {} }); -``` - -The return type is now inferred from the method name via `ResultTypeMap`. For example, `client.request({ method: 'tools/call', ... })` returns `Promise`. - -For **custom (non-spec)** methods, keep the result-schema argument — see [Sending custom-method requests](#sending-custom-method-requests). Only drop the schema when calling a spec method. - -If you were using `CallToolResultSchema` (or any `*Schema` constant) for **runtime validation** (not just in `request()`/`callTool()` calls), use `isSpecType` or `specTypeSchemas`: - -```typescript -// v1: runtime validation with Zod schema -import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; -if (CallToolResultSchema.safeParse(value).success) { - /* ... */ -} - -// v2: keyed type predicate -import { isSpecType } from '@modelcontextprotocol/client'; -if (isSpecType.CallToolResult(value)) { - /* ... */ -} -const blocks = mixed.filter(isSpecType.ContentBlock); - -// v2: or get the StandardSchemaV1Sync validator object directly -import { specTypeSchemas } from '@modelcontextprotocol/client'; -const result = specTypeSchemas.CallToolResult['~standard'].validate(value); -``` - -`isSpecType` and `specTypeSchemas` are keyed by `SpecTypeName` — a literal union of every named type in the MCP spec — so you get autocomplete and a compile error on typos. `specTypeSchemas.X` is a `StandardSchemaV1Sync` — `validate()` returns the result synchronously, -so you can access `.issues` / `.value` without `await`. It composes with any Standard-Schema-aware library. The pre-existing `isCallToolResult(value)` guard still works. - -### Client list methods return empty results for missing capabilities - -`Client.listPrompts()`, `listResources()`, `listResourceTemplates()`, and `listTools()` now return empty results when the server didn't advertise the corresponding capability, instead of sending the request. This respects the MCP spec's capability negotiation. - -To restore v1 behavior (throw an error when capabilities are missing), set `enforceStrictCapabilities: true`: - -```typescript -const client = new Client( - { name: 'my-client', version: '1.0.0' }, - { - enforceStrictCapabilities: true - } -); -``` - -### `InMemoryTransport` moved - -`InMemoryTransport` is now exported from `@modelcontextprotocol/client` and `@modelcontextprotocol/server` (both re-export it). It is still intended for in-process client-server connections and testing. - -```typescript -// v1 -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; - -// v2 -import { InMemoryTransport } from '@modelcontextprotocol/server'; -// or -import { InMemoryTransport } from '@modelcontextprotocol/client'; -``` - -### Removed type aliases and deprecated exports - -The following deprecated type aliases have been removed from `@modelcontextprotocol/core`: - -| Removed | Replacement | -| ---------------------------------------- | ------------------------------------------------------------------------------------------------- | -| `JSONRPCError` | `JSONRPCErrorResponse` | -| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | -| `isJSONRPCError` | `isJSONRPCErrorResponse` | -| `isJSONRPCResponse` | `isJSONRPCResultResponse` (see note below) | -| `ResourceReferenceSchema` | `ResourceTemplateReferenceSchema` | -| `ResourceReference` | `ResourceTemplateReference` | -| `IsomorphicHeaders` | Use Web Standard `Headers` | -| `AuthInfo` (from `server/auth/types.js`) | `AuthInfo` (now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`) | - -All other types and schemas exported from `@modelcontextprotocol/sdk/types.js` retain their original names — import them from `@modelcontextprotocol/client` or `@modelcontextprotocol/server`. - -> **Note on `isJSONRPCResponse`:** v1's `isJSONRPCResponse` was a deprecated alias that only checked for _result_ responses (it was equivalent to `isJSONRPCResultResponse`). v2 removes the deprecated alias and introduces a **new** `isJSONRPCResponse` with corrected semantics — it -> checks for _any_ response (either result or error). If you are migrating v1 code that used `isJSONRPCResponse`, rename it to `isJSONRPCResultResponse` to preserve the original behavior. Use the new `isJSONRPCResponse` only when you want to match both result and error responses. - -**Before (v1):** - -```typescript -import { JSONRPCError, ResourceReference, isJSONRPCError } from '@modelcontextprotocol/sdk/types.js'; -``` - -**After (v2):** - -```typescript -import { JSONRPCErrorResponse, ResourceTemplateReference, isJSONRPCErrorResponse } from '@modelcontextprotocol/server'; -``` - -### Request handler context types - -The `RequestHandlerExtra` type has been replaced with a structured context type hierarchy using nested groups: - -| v1 | v2 | -| ------------------------------------------------- | ---------------------------------------------------------------------- | -| `RequestHandlerExtra` (flat, all fields) | `ServerContext` (server handlers) or `ClientContext` (client handlers) | -| `extra` parameter name | `ctx` parameter name | -| `extra.signal` | `ctx.mcpReq.signal` | -| `extra.requestId` | `ctx.mcpReq.id` | -| `extra._meta` | `ctx.mcpReq._meta` | -| `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` | -| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` | -| `extra.authInfo` | `ctx.http?.authInfo` | -| `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only on `ServerContext`) | -| `extra.closeSSEStream` | `ctx.http?.closeSSE` (only on `ServerContext`) | -| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only on `ServerContext`) | -| `extra.sessionId` | `ctx.sessionId` | -| `extra.taskStore` / `taskId` / `taskRequestedTtl` | _removed — see "Experimental tasks interception removed" below_ | - -**Before (v1):** - -```typescript -server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { - const headers = extra.requestInfo?.headers; - const taskStore = extra.taskStore; - await extra.sendNotification({ method: 'notifications/progress', params: { progressToken: 'abc', progress: 50, total: 100 } }); - return { content: [{ type: 'text', text: 'result' }] }; -}); -``` - -**After (v2):** - -```typescript -server.setRequestHandler('tools/call', async (request, ctx) => { - const headers = ctx.http?.req?.headers; // standard Web Request object - await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'abc', progress: 50, total: 100 } }); - return { content: [{ type: 'text', text: 'result' }] }; -}); -``` - -Context fields are organized into 3 groups: - -- **`mcpReq`** — request-level concerns: `id`, `method`, `_meta`, `signal`, `send()`, `notify()`, plus server-only `log()`, `elicitInput()`, and `requestSampling()` -- **`http?`** — HTTP transport concerns (undefined for stdio): `authInfo`, plus server-only `req`, `closeSSE`, `closeStandaloneSSE` -- **`sessionId?`** — transport session identifier (top-level) - -`BaseContext` is the common base type shared by both `ServerContext` and `ClientContext`. `ServerContext` extends each group with server-specific additions via type intersection. - -`ServerContext` also provides convenience methods for common server→client operations: - -```typescript -server.setRequestHandler('tools/call', async (request, ctx) => { - // Send a log message (respects client's log level filter) - await ctx.mcpReq.log('info', 'Processing tool call', 'my-logger'); - - // Request client to sample an LLM - const samplingResult = await ctx.mcpReq.requestSampling({ - messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], - maxTokens: 100 - }); - - // Elicit user input via a form - const elicitResult = await ctx.mcpReq.elicitInput({ - message: 'Please provide details', - requestedSchema: { type: 'object', properties: { name: { type: 'string' } } } - }); - - return { content: [{ type: 'text', text: 'done' }] }; -}); -``` - -These replace the pattern of calling `server.sendLoggingMessage()`, `server.createMessage()`, and `server.elicitInput()` from within handlers. - -### Error hierarchy refactoring - -The SDK now distinguishes between three types of errors: - -1. **`ProtocolError`** (renamed from `McpError`): Protocol errors that cross the wire as JSON-RPC error responses -2. **`SdkError`**: Local SDK errors that never cross the wire (timeouts, connection issues, capability checks) -3. **`SdkHttpError`** (extends `SdkError`): HTTP transport errors with typed `.status` and `.statusText` accessors - -#### Renamed exports - -| v1 | v2 | -| ---------------------------- | ------------------------------- | -| `McpError` | `ProtocolError` | -| `ErrorCode` | `ProtocolErrorCode` | -| `ErrorCode.RequestTimeout` | `SdkErrorCode.RequestTimeout` | -| `ErrorCode.ConnectionClosed` | `SdkErrorCode.ConnectionClosed` | - -**Before (v1):** - -```typescript -import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; - -try { - await client.callTool({ name: 'test', arguments: {} }); -} catch (error) { - if (error instanceof McpError && error.code === ErrorCode.RequestTimeout) { - console.log('Request timed out'); - } - if (error instanceof McpError && error.code === ErrorCode.InvalidParams) { - console.log('Invalid parameters'); - } -} -``` - -**After (v2):** - -```typescript -import { ProtocolError, ProtocolErrorCode, SdkError, SdkErrorCode } from '@modelcontextprotocol/client'; - -try { - await client.callTool({ name: 'test', arguments: {} }); -} catch (error) { - // Local timeout/connection errors are now SdkError - if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) { - console.log('Request timed out'); - } - // Protocol errors from the server are still ProtocolError - if (error instanceof ProtocolError && error.code === ProtocolErrorCode.InvalidParams) { - console.log('Invalid parameters'); - } -} -``` - -#### New `SdkErrorCode` enum - -The new `SdkErrorCode` enum contains string-valued codes for local SDK errors: - -| Code | Description | -| ------------------------------------------------- | ---------------------------------------------- | -| `SdkErrorCode.NotConnected` | Transport is not connected | -| `SdkErrorCode.AlreadyConnected` | Transport is already connected | -| `SdkErrorCode.NotInitialized` | Protocol is not initialized | -| `SdkErrorCode.CapabilityNotSupported` | Required capability is not supported | -| `SdkErrorCode.RequestTimeout` | Request timed out waiting for response | -| `SdkErrorCode.ConnectionClosed` | Connection was closed | -| `SdkErrorCode.SendFailed` | Failed to send message | -| `SdkErrorCode.InvalidResult` | Response result failed local schema validation | -| `SdkErrorCode.ClientHttpNotImplemented` | HTTP POST request failed | -| `SdkErrorCode.ClientHttpAuthentication` | Server returned 401 after re-authentication | -| `SdkErrorCode.ClientHttpForbidden` | Server returned 403 after trying upscoping | -| `SdkErrorCode.ClientHttpUnexpectedContent` | Unexpected content type in HTTP response | -| `SdkErrorCode.ClientHttpFailedToOpenStream` | Failed to open SSE stream | -| `SdkErrorCode.ClientHttpFailedToTerminateSession` | Failed to terminate session | - -#### `StreamableHTTPError` removed - -The `StreamableHTTPError` class has been removed. HTTP transport errors are now thrown as `SdkHttpError` (a subclass of `SdkError` with typed `.status` and `.statusText` accessors) with specific `SdkErrorCode` values that provide more granular error information: - -**Before (v1):** - -```typescript -import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; - -try { - await transport.send(message); -} catch (error) { - if (error instanceof StreamableHTTPError) { - console.log('HTTP error:', error.code); // HTTP status code - } -} -``` - -**After (v2):** - -```typescript -import { SdkHttpError, SdkErrorCode } from '@modelcontextprotocol/client'; - -try { - await transport.send(message); -} catch (error) { - if (error instanceof SdkHttpError) { - console.log('HTTP status:', error.status); // number — no cast needed - console.log('Status text:', error.statusText); // string | undefined - switch (error.code) { - case SdkErrorCode.ClientHttpAuthentication: - console.log('Auth failed — server rejected token after re-auth'); - break; - case SdkErrorCode.ClientHttpForbidden: - console.log('Forbidden after upscoping attempt'); - break; - case SdkErrorCode.ClientHttpFailedToOpenStream: - console.log('Failed to open SSE stream'); - break; - case SdkErrorCode.ClientHttpNotImplemented: - console.log('HTTP request failed'); - break; - } - } -} -``` - -#### Why this change? - -Previously, `ErrorCode.RequestTimeout` (-32001) and `ErrorCode.ConnectionClosed` (-32000) were used for local timeout/connection errors. However, these errors never cross the wire as JSON-RPC responses - they are rejected locally. Using protocol error codes for local errors was -semantically inconsistent. - -The new design: - -- `ProtocolError` with `ProtocolErrorCode`: For errors that are serialized and sent as JSON-RPC error responses -- `SdkError` with `SdkErrorCode`: For local errors that are thrown/rejected locally and never leave the SDK - -### OAuth error refactoring - -The OAuth error classes have been consolidated into a single `OAuthError` class with an `OAuthErrorCode` enum. - -#### Removed classes - -The following individual error classes have been removed in favor of `OAuthError` with the appropriate code: - -| v1 Class | v2 Equivalent | -| ------------------------------ | ----------------------------------------------------------------- | -| `InvalidRequestError` | `new OAuthError(OAuthErrorCode.InvalidRequest, message)` | -| `InvalidClientError` | `new OAuthError(OAuthErrorCode.InvalidClient, message)` | -| `InvalidGrantError` | `new OAuthError(OAuthErrorCode.InvalidGrant, message)` | -| `UnauthorizedClientError` | `new OAuthError(OAuthErrorCode.UnauthorizedClient, message)` | -| `UnsupportedGrantTypeError` | `new OAuthError(OAuthErrorCode.UnsupportedGrantType, message)` | -| `InvalidScopeError` | `new OAuthError(OAuthErrorCode.InvalidScope, message)` | -| `AccessDeniedError` | `new OAuthError(OAuthErrorCode.AccessDenied, message)` | -| `ServerError` | `new OAuthError(OAuthErrorCode.ServerError, message)` | -| `TemporarilyUnavailableError` | `new OAuthError(OAuthErrorCode.TemporarilyUnavailable, message)` | -| `UnsupportedResponseTypeError` | `new OAuthError(OAuthErrorCode.UnsupportedResponseType, message)` | -| `UnsupportedTokenTypeError` | `new OAuthError(OAuthErrorCode.UnsupportedTokenType, message)` | -| `InvalidTokenError` | `new OAuthError(OAuthErrorCode.InvalidToken, message)` | -| `MethodNotAllowedError` | `new OAuthError(OAuthErrorCode.MethodNotAllowed, message)` | -| `TooManyRequestsError` | `new OAuthError(OAuthErrorCode.TooManyRequests, message)` | -| `InvalidClientMetadataError` | `new OAuthError(OAuthErrorCode.InvalidClientMetadata, message)` | -| `InsufficientScopeError` | `new OAuthError(OAuthErrorCode.InsufficientScope, message)` | -| `InvalidTargetError` | `new OAuthError(OAuthErrorCode.InvalidTarget, message)` | -| `CustomOAuthError` | `new OAuthError(customCode, message)` | - -The `OAUTH_ERRORS` constant has also been removed. - -If you need the v1 OAuth error classes and `mcpAuthRouter` during migration, `@modelcontextprotocol/server-legacy/auth` provides a frozen copy: - -```typescript -import { mcpAuthRouter, InvalidClientError } from '@modelcontextprotocol/server-legacy/auth'; -``` - -This package is deprecated and will not receive new features. Use a dedicated OAuth provider in production. - -**Before (v1):** - -```typescript -import { InvalidClientError, InvalidGrantError, ServerError } from '@modelcontextprotocol/client'; - -try { - await refreshToken(); -} catch (error) { - if (error instanceof InvalidClientError) { - // Handle invalid client - } else if (error instanceof InvalidGrantError) { - // Handle invalid grant - } else if (error instanceof ServerError) { - // Handle server error - } -} -``` - -**After (v2):** - -```typescript -import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/client'; - -try { - await refreshToken(); -} catch (error) { - if (error instanceof OAuthError) { - switch (error.code) { - case OAuthErrorCode.InvalidClient: - // Handle invalid client - break; - case OAuthErrorCode.InvalidGrant: - // Handle invalid grant - break; - case OAuthErrorCode.ServerError: - // Handle server error - break; - } - } -} -``` - -### Experimental tasks interception removed - -The 2025-11 experimental tasks side-channel woven through `Protocol` has been removed in preparation for the SEP-2663 Tasks Extension. The following are gone with no in-place replacement: - -- `ProtocolOptions.tasks` (the `{ taskStore, taskMessageQueue }` constructor option) -- `protocol.taskManager` getter, `Protocol#_bindTaskManager` -- `RequestOptions.task` / `RequestOptions.relatedTask`, `NotificationOptions.relatedTask` -- `BaseContext.task` (`ctx.task?.store` / `ctx.task?.id` / `ctx.task?.requestedTtl`) -- abstract `assertTaskCapability` / `assertTaskHandlerCapability` -- `client.experimental.tasks.*` / `server.experimental.tasks.*` / `mcpServer.experimental.tasks.*` accessors and the `Experimental{Client,Server,McpServer}Tasks` classes -- streaming methods (`requestStream`, `callToolStream`, `createMessageStream`, `elicitInputStream`) and the `ResponseMessage` types they yielded (`BaseResponseMessage`, `ErrorMessage`, `AsyncGeneratorValue`) -- `mcpServer.experimental.tasks.registerToolTask(...)`, `ToolTaskHandler`, `TaskRequestHandler`, `CreateTaskRequestHandler` -- `TaskMessageQueue`, `InMemoryTaskMessageQueue`, `BaseQueuedMessage` and the `Queued*` message types, `CreateTaskServerContext`, `TaskServerContext`, `TaskToolExecution` -- `examples/{client,server}/src/simpleTaskInteractive*.ts` - -**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. - -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. - -## Enhancements - -### Automatic JSON Schema validator selection by runtime - -The SDK now automatically selects the appropriate JSON Schema validator based on your runtime environment: - -- **Node.js**: Uses AJV (same as v1 default) -- **Cloudflare Workers**: Uses `@cfworker/json-schema` (previously required manual configuration) - -This means Cloudflare Workers users no longer need to explicitly pass the validator: - -**Before (v1) - Cloudflare Workers required explicit configuration:** - -```typescript -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/cfworker'; - -const server = new McpServer( - { name: 'my-server', version: '1.0.0' }, - { - capabilities: { tools: {} }, - jsonSchemaValidator: new CfWorkerJsonSchemaValidator() // Required in v1 - } -); -``` - -**After (v2) - Works automatically:** - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; - -const server = new McpServer( - { name: 'my-server', version: '1.0.0' }, - { capabilities: { tools: {} } } - // Validator auto-selected based on runtime -); -``` - -You do not need to install or import validator packages for the default behavior. The client and server packages bundle the validator backend selected by the runtime shim, so a normal `import { McpServer } from '@modelcontextprotocol/server'` does not pull `ajv` or -`@cfworker/json-schema` into your bundle until you choose to customize. - -If you want to customize the **built-in** backend (for example, pre-register schemas by `$id`, register custom AJV formats, or change the `@cfworker/json-schema` draft), import the named class from the explicit subpath and pass an instance through `jsonSchemaValidator`: - -```typescript -import { Ajv } from 'ajv'; -import addFormats from 'ajv-formats'; -import { AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv'; - -const ajv = new Ajv({ strict: true, allErrors: true }); -addFormats(ajv); - -const server = new McpServer( - { name: 'my-server', version: '1.0.0' }, - { - capabilities: { tools: {} }, - jsonSchemaValidator: new AjvJsonSchemaValidator(ajv) - } -); -``` - -```typescript -import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker'; - -const server = new McpServer( - { name: 'my-server', version: '1.0.0' }, - { - capabilities: { tools: {} }, - jsonSchemaValidator: new CfWorkerJsonSchemaValidator({ draft: '2020-12', shortcircuit: false }) - } -); -``` - -(both subpaths are also available on `@modelcontextprotocol/client/validators/...`) - -If you import from one of these subpaths in your own code, the corresponding peer dep (`ajv` + `ajv-formats`, or `@cfworker/json-schema`) needs to be installed in your `package.json`. The runtime shim continues to vendor a copy for the default code path, so you can use the -subpath in some files and rely on the default in others. - -To replace validation wholesale rather than customizing the built-in classes, implement the `jsonSchemaValidator` interface and pass your own implementation through the option above. - -## Unchanged APIs - -The following APIs are unchanged between v1 and v2 (only the import paths changed): - -- `Client` constructor and most client methods (`connect`, `listTools`, `listPrompts`, `listResources`, `readResource`, etc.) — note: `callTool()` signature changed (schema parameter removed) -- `McpServer` constructor, `server.connect(transport)`, `server.close()` -- `Server` (low-level) constructor and all methods -- `StreamableHTTPClientTransport`, `SSEClientTransport`, `StdioClientTransport` constructors and options -- `StdioServerTransport` constructor and options -- All Zod schemas and type definitions from `types.ts` (except the aliases listed above) -- Tool, prompt, and resource callback return types - -**Session-ID mismatch responses**: when session management is enabled and a request carries an `Mcp-Session-Id` header that doesn't match the active session, the Streamable HTTP server transport responds `404 Not Found` with a JSON-RPC error body using code `-32001` and -message `Session not found` — unchanged from v1. Note that this use of `-32001` is an SDK convention, not a spec-assigned error code, and it is expected to be re-derived as error handling for the 2026 protocol revision (`2026-07-28`) is adopted. Avoid hard-coding the -`-32001` code in client logic; key off the HTTP `404` status instead. - -## Using an LLM to migrate your code - -An LLM-optimized version of this guide is available at [`docs/migration-SKILL.md`](migration-SKILL.md). It contains dense mapping tables designed for tools like Claude Code to mechanically apply all the changes described above. You can paste it into your LLM context or load it as -a skill. - -## Need Help? - -If you encounter issues during migration: - -1. Check the [FAQ](faq.md) for common questions about v2 changes -2. Review the [examples](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) for updated usage patterns -3. Open an issue on [GitHub](https://github.com/modelcontextprotocol/typescript-sdk/issues) if you find a bug or need further assistance diff --git a/docs/migration/index.md b/docs/migration/index.md new file mode 100644 index 0000000000..2e9d8bd99a --- /dev/null +++ b/docs/migration/index.md @@ -0,0 +1,44 @@ +# MCP TypeScript SDK — Migration Guides + +Pick the guide for your starting point. + +## Upgrading from v1.x (`@modelcontextprotocol/sdk`) + +→ **[upgrade-to-v2.md](./upgrade-to-v2.md)** + +You are on the monolithic `@modelcontextprotocol/sdk` package and want to move to the +v2 packages (`@modelcontextprotocol/client`, `@modelcontextprotocol/server`, …). + +Start by running the codemod: + +```bash +npx @modelcontextprotocol/codemod@alpha v1-to-v2 ./src +``` + +The codemod handles most mechanical renames. The guide covers what it can't. The +codemod handles the v1→v2 SDK surface upgrade only — adopting the 2026-07-28 protocol +revision (`createMcpHandler`, multi-round-trip requests, `versionNegotiation`) is +architectural and not codemod-automatable; see [support-2026-07-28.md](./support-2026-07-28.md). + +## Already on v2, adopting protocol revision 2026-07-28 + +→ **[support-2026-07-28.md](./support-2026-07-28.md)** + +You are already on the v2 packages and want your server or client to speak the +2026-07-28 protocol revision (per-request `_meta` envelope, `createMcpHandler`, +`serveStdio`, `versionNegotiation`, multi-round-trip requests, per-era wire codecs). + +This guide also covers code written against an earlier **v2 alpha** that read +wire-only members (`resultType`, envelope keys) directly. + +## Using an LLM agent to migrate + +[upgrade-to-v2.md](./upgrade-to-v2.md) is the agent skill — it carries skill +frontmatter and is structured for mechanical application. Point the agent at +the codemod first; the guide is the codemod's companion for what's left. + +## See also + +- [`@modelcontextprotocol/codemod` README](../../packages/codemod/README.md) +- [FAQ](../faq.md) +- [Examples](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md new file mode 100644 index 0000000000..a094123616 --- /dev/null +++ b/docs/migration/support-2026-07-28.md @@ -0,0 +1,443 @@ +# Supporting protocol revision 2026-07-28 + +This guide is for code **already on the v2 packages** that wants to speak the 2026-07-28 +protocol revision — and for code written against an earlier **v2 alpha** that read +wire-only members directly. If you are on `@modelcontextprotocol/sdk` (v1.x), start with +[upgrade-to-v2.md](./upgrade-to-v2.md) instead. + +Nothing in v2 puts a 2026-07-28 byte on the wire by default: a hand-constructed +`Client` / `Server` / `McpServer` keeps speaking the 2025-era protocol it was written +for. Serving or speaking 2026-07-28 is always an explicit opt-in via one of the entries +below. + +## Contents + +- [Serving the 2026-07-28 revision](#serving-the-2026-07-28-revision) +- [Replacing per-session state: `requestState`](#replacing-per-session-state-requeststate) +- [Auth on 2026-07-28](#auth-on-2026-07-28) +- [Per-era wire codecs](#per-era-wire-codecs) +- [Wire-only members hidden from public types](#wire-only-members-hidden-from-public-types) +- [Multi-round-trip requests](#multi-round-trip-requests) +- [`subscriptions/listen`](#subscriptionslisten) +- [`Mcp-Param-*` and standard headers (SEP-2243)](#mcp-param--and-standard-headers-sep-2243) +- [Cache fields and cache hints](#cache-fields-and-cache-hints) +- [Tasks: deprecated wire vocabulary](#tasks-deprecated-wire-vocabulary) +- [Appendix: 2025-era vs 2026-era behavior matrix](#appendix-2025-era-vs-2026-era-behavior-matrix) + +--- + +## Serving the 2026-07-28 revision + +These entry points are documented in full in the server and client guides; this section +contextualizes them as the migration path. + +### Client side: `versionNegotiation` + +By default `Client.connect()` performs the same 2025 `initialize` handshake as v1.x, +byte for byte. To negotiate the 2026-07-28 era, opt in via `ClientOptions.versionNegotiation` — +see [client.md › Protocol version negotiation](../client.md#protocol-version-negotiation-2026-07-28-revision). + +```typescript +const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); +await client.connect(transport); +client.getProtocolEra(); // 'modern' | 'legacy' +``` + +- **absent / `mode: 'legacy'`** (default) — today's behavior, no probe. +- **`mode: 'auto'`** — probe with `server/discover`; fall back to the 2025 handshake on + the same connection against a 2025-only server (one extra round trip). +- **`mode: { pin: '2026-07-28' }`** — modern only; no fallback, `connect()` rejects with + `SdkError(EraNegotiationFailed)` against a 2025-only server. + +#### Probe policy + +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 `connect()` rejects with +`SdkError(EraNegotiationFailed)` instead. A network outage rejects with a typed connect +error. Probe timeouts are **transport-aware**: on **stdio** a server that does not +answer within `timeoutMs` is treated as legacy and the client falls back to `initialize` +on the same stream (some legacy servers never respond to unknown pre-`initialize` +requests at all); on **HTTP** a probe timeout rejects with `SdkError(RequestTimeout)` — +a dead HTTP server is never misreported as legacy. 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. + +```typescript +versionNegotiation: { + mode: 'auto', + probe: { + timeoutMs: 10_000, // default: the standard request timeout + maxRetries: 0 // default: no retries — governs timeout re-sends only + } +} +``` + +`maxRetries` governs timeout re-sends only (the spec-mandated `-32022` corrective +continuation — select-and-continue with a mutual version — is a separate negotiation step +and is never counted against it). + +Once a modern era is negotiated the client auto-attaches the per-request `_meta` +envelope to every outgoing request and notification. A gateway/worker fleet can skip the +probe entirely with `client.connect(transport, { prior: persistedDiscoverResult })`. + +### Server over HTTP: `createMcpHandler` + +`createMcpHandler(factory)` from `@modelcontextprotocol/server` is the v2 HTTP entry +that serves 2026-07-28 per request — and, by default (`legacy: 'stateless'`), also +serves 2025-era traffic per request through the established stateless idiom. One +factory, one endpoint, both eras. + +```typescript +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; + +const handler = createMcpHandler(() => { + 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; +}); +// Web-standard runtimes: export default handler; +// Node frameworks: app.all('/mcp', toNodeHandler(handler)) from @modelcontextprotocol/node +``` + +A v1 stateless `StreamableHTTPServerTransport` hosting (`sessionIdGenerator: undefined`, +fresh transport per request) maps directly onto the default entry. An existing +**sessionful** v1 Streamable HTTP setup keeps serving 2025 clients by routing it in +front of a strict (`legacy: 'reject'`) entry with `isLegacyRequest(request)`: + +```typescript +const modern = createMcpHandler(factory, { legacy: 'reject' }); +export default { + async fetch(request: Request) { + if (await isLegacyRequest(request)) return myExistingLegacyHandler(request); + return modern.fetch(request); + } +}; +``` + +`isLegacyRequest` returns `true` only for requests with no per-request `_meta` envelope +claim; route `false` traffic to the modern handler (a malformed modern claim is `false` +and answered `-32602` / `-32020` by the modern path). The handler is web-standards-only +(`{ fetch, close, notify, bus }`); on Node frameworks wrap once with +`toNodeHandler(handler, { onerror? })` from `@modelcontextprotocol/node`. The exported +`legacyStatelessFallback(factory)` is the same stateless 2025 serving as a standalone +fetch-shaped handler. + +> **If you were on a v2 alpha:** `handler.node(req, res, body)` is gone — replace with +> `toNodeHandler(handler)` and add the `@modelcontextprotocol/node` import. +> `NodeIncomingMessageLike` / `NodeServerResponseLike` are now exported from +> `@modelcontextprotocol/node`, not `@modelcontextprotocol/server`. + +### Server over stdio / long-lived connections: `serveStdio` + +A hand-constructed `Server`/`McpServer` connected directly to a `StdioServerTransport` +serves only the 2025-era protocol — upgrading the SDK changes nothing about what it puts +on the wire. Serving 2026-07-28 (or both eras) on stdio goes through the +connection-pinned `serveStdio(() => buildServer())` entry from +`@modelcontextprotocol/server/stdio`; the opening exchange selects the connection's era, +and one factory instance is pinned per connection. See +[server.md › Serving the 2026-07-28 draft revision on stdio](../server.md#serving-the-2026-07-28-draft-revision-on-stdio). + +To migrate an existing stdio server, replace +`await server.connect(new StdioServerTransport())` with +`serveStdio(() => buildServer())`. Pass `{ legacy: 'reject' }` to refuse 2025-era +openings. On 2026-pinned connections, `getClientCapabilities()` / `getClientVersion()` +return `undefined` (no `initialize` ever runs there) and handlers read per-request +identity from `ctx.mcpReq.envelope`; `getNegotiatedProtocolVersion()` reports the pinned +revision. + +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. + +### Client cancellation on Streamable HTTP + +On a 2026-07-28 Streamable HTTP connection, aborting an in-flight client request +(`signal` / timeout) closes that request's SSE response stream — the spec cancellation +signal — instead of POSTing `notifications/cancelled`. Nothing to change in calling +code. 2025-era connections and stdio at any era still send `notifications/cancelled`. +Custom `Transport` implementations that open one underlying request per outbound message +and honor `TransportSendOptions.requestSignal` may opt in by declaring +`readonly hasPerRequestStream = true`. + +### `ctx.mcpReq.log()` and the per-request `logLevel` + +On a 2026-07-28 request, `ctx.mcpReq.log()` reads its level filter from the +`io.modelcontextprotocol/logLevel` `_meta` envelope key (the modern replacement for the +`logging/setLevel` RPC). When the key is **absent** the server emits no +`notifications/message` for that request — absence is opt-out, not "no filter". The SDK +`Client` does not auto-attach `logLevel`, so handler logs on a default 2026-era exchange +are silently suppressed until the client opts in. + +--- + +## Replacing per-session state: `requestState` + +The 2026-07-28 revision is **per request** — `createMcpHandler` builds a fresh server per +request and there is no `Mcp-Session-Id`. If your v1 server kept state keyed on the +session id (`ctx.sessionId` / `extra.sessionId`), the 2026 answer is `requestState`: an +opaque string the server returns with `inputRequired(...)` and the client echoes +byte-for-byte on the retry. Read it at `ctx.mcpReq.requestState`. + +`requestState` round-trips through the client and is therefore **untrusted input** — +integrity-protect it (HMAC / AEAD over the payload, bound to principal, originating +method/parameters, and an expiry) and reject failed verification on re-entry. Configure +`ServerOptions.requestState.verify` and the seam runs it before the handler whenever +`requestState` is present (a thrown rejection answers `-32602` above the tool funnel). +The `createRequestStateCodec({ key, ttlSeconds?, bind? })` helper returns +`{ mint, verify }` — `mint` HMAC-SHA256-seals a JSON-serializable payload and `verify` +is exactly the function you assign to the hook. The codec is **signed, not encrypted** +(the client can base64url-decode the payload). See `examples/mrtr/server.ts` and +[Multi-round-trip requests](#multi-round-trip-requests) for the full handler shape. + +--- + +## Auth on 2026-07-28 + +The 2026-07-28 specification's authorization requirements (RFC 9207 `iss` validation, +SEP-2352 credential isolation, SEP-2350 scope step-up, SEP-837/SEP-2207 DCR + TLS) are +implemented in v2 as **SDK-level opt-ins, not protocol-era gates** — they apply on every +era once enabled. The migration steps live in +[upgrade-to-v2.md › Auth](./upgrade-to-v2.md#auth). To be **2026-07-28-conformant**, +enable the spec-2026 opt-ins listed there: pass `iss` (or the callback `URLSearchParams`) +to `finishAuth`; round-trip the `issuer` stamp on stored credentials; implement +`discoveryState()`; and either keep `onInsufficientScope: 'reauthorize'` or handle +`InsufficientScopeError` yourself. Nothing in this section is era-switched at the wire +layer. + +--- + +## Per-era wire codecs + +The wire layer is 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 (instances with no negotiated +version default to the 2025 era). 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 (`-32022 Unsupported protocol version` +for requests; drop + `onerror` for notifications). + +Methods deleted by a protocol revision are **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 (e.g. `server/discover` +toward a 2025-era peer, or any `tasks/*` method toward a 2026-era peer) throws +`SdkError(MethodNotSupportedByProtocolVersion)` before anything reaches the transport. + +If you were on a v2 alpha and consumed wire schemas directly: + +| v2-alpha pattern | Mechanical fix | +| --- | --- | +| parsing wire bytes with `EmptyResultSchema` that may carry `resultType` | strip `resultType` first (the schema now rejects it as an unknown key) | +| `specTypeSchemas` / `SpecTypeName` references to task message types or `RequestMetaEnvelope` | remove — these validators left the public set (the **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 | + +The `resultType` / `EmptyResultSchema` / `specTypeSchemas` rules above have **no v1.x +impact** — these members did not exist before 2026-07-28. The neutral-model wire +tightening that **does** affect v1 code (`content` required, custom-handler `_meta` +passthrough, `specTypeSchemas` narrowing) is in +[upgrade-to-v2.md › Wire tightening](./upgrade-to-v2.md#wire-tightening-every-era). + +> **If you were on a v2 alpha:** the 2026-07-28 draft error codes were renumbered: +> `HeaderMismatch` `-32001`→`-32020`, `MissingRequiredClientCapability` `-32003`→`-32021`, +> `UnsupportedProtocolVersion` `-32004`→`-32022`. No v1.x impact (these codes never +> existed in v1); v2-alpha code that hard-coded the old literals must update — prefer +> `ProtocolErrorCode.*` / `HEADER_MISMATCH_ERROR_CODE`. + +--- + +## Wire-only members hidden from public types + +The 2026-07-28 wire-level bookkeeping is handled internally and never reaches +application code: the `resultType` discrimination field, the reserved per-request +`_meta` envelope keys (`io.modelcontextprotocol/{protocolVersion,clientInfo,clientCapabilities,logLevel}`), +and the multi-round-trip retry fields (`inputResponses`, `requestState`). + +- **`resultType` is gone from every public result type** (`Result`, `CallToolResult`, + `GetPromptResult`, …). The wire schemas keep parsing it, and the protocol layer + consumes it before results reach your code. +- **High-level methods return the named public types** (`client.callTool()` → + `Promise`, etc.). Handler return positions are unaffected. +- **Reserved envelope keys and retry fields appear in no public params/result type.** + The `RequestMetaEnvelope` type and the four `*_META_KEY` constants stay exported. + +The protocol layer enforces the same boundary at runtime: + +- **Envelope lift.** On inbound requests and notifications, the reserved + `io.modelcontextprotocol/*` keys are lifted out of `params._meta` before handlers run. + For requests the envelope is readable at `ctx.mcpReq.envelope` + (typed `Partial`); for notifications there is no per-message + context, so lifted envelope keys are dropped. On requests only, `inputResponses` / + `requestState` are lifted from top-level params to `ctx.mcpReq.inputResponses` / + `ctx.mcpReq.requestState`; notification params are never touched. +- **Collision note for 2025-era peers.** The `_meta` lift is invisible to conforming + 2025 traffic (the `io.modelcontextprotocol/` prefix is reserved in 2025-11-25 too). + The retry-field lift is the one collision: 2025-11-25 does not reserve the bare names + `inputResponses`/`requestState`, so a 2025 peer's **custom-method request** that uses + them as ordinary top-level params has them lifted out of `request.params` (still + readable at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`). +- **Raw-first result discrimination.** On a 2026-era exchange, `'complete'` is consumed + and stripped; `'input_required'` is fulfilled by the client's auto-fulfilment driver; + any other kind rejects with `SdkError(UnsupportedResultType)` (kind in + `error.data.resultType`). On a 2025-era connection a foreign `resultType` is stripped + before validation. On a 2026-era exchange `resultType` is REQUIRED; an absent value is + a spec violation surfaced as a typed error. + +**If you were on a v2 alpha** and read the wire shape directly: + +| Pattern | 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`, …) | + +`MessageExtraInfo.classification` is an optional carrier (`{ era, revision?, envelope? }`) +for transports that classify inbound messages at the edge; dispatch validates it against +the instance's negotiated era. + +--- + +## Multi-round-trip requests + +The 2026-07-28 revision removes the server→client JSON-RPC request channel. Servers +obtain client input (elicitation, sampling, roots) **in-band** by returning +`inputRequired(...)` from a `tools/call` / `prompts/get` / `resources/read` handler; the +client retries the original call with the responses. + +| Handler serving 2026-07-28 requests | Mechanical fix | +| --- | --- | +| `await ctx.mcpReq.elicitInput({…})` / `requestSampling({…})` | `return inputRequired({ inputRequests: { id: inputRequired.elicit({…}) } })`; read `acceptedContent(ctx.mcpReq.inputResponses, 'id')` on re-entry | +| `throw new UrlElicitationRequiredError([…])` | `return inputRequired({ inputRequests: { id: inputRequired.elicitUrl({…}) } })` | +| handler shared across both eras | branch on the served era: keep the push-style call toward 2025-era requests, return `inputRequired(...)` toward 2026-07-28 requests | + +`inputRequired` / `acceptedContent` / `InputRequiredSpec` are exported from +`@modelcontextprotocol/server`. On 2026-era requests the push-style APIs +(`ctx.mcpReq.send` of server→client requests, `ctx.mcpReq.elicitInput`, +`ctx.mcpReq.requestSampling`, instance-level `createMessage()`/`elicitInput()`/`listRoots()`/`ping()`) +fail with a typed local error before anything reaches the wire; their behavior toward +2025-era requests is unchanged. + +`requestState` round-trips as an opaque, **untrusted** string — see +[Replacing per-session state: `requestState`](#replacing-per-session-state-requeststate) +for the sealing helper and verification hook. + +**Client side — auto-fulfilment by default.** When a 2026-07-28 call answers +`input_required`, the client fulfils the embedded requests through the same handlers +registered with `setRequestHandler('elicitation/create' | 'sampling/createMessage' | +'roots/list', …)` and retries (fresh request id, `inputResponses`, byte-exact +`requestState` echo) up to `inputRequired.maxRounds` rounds (default 10). Configure or +opt out via `ClientOptions.inputRequired` (`{ autoFulfill: false }`); drive manually per +call with `allowInputRequired: true` plus `withInputRequired()`. Expect +`SdkError(InputRequiredRoundsExceeded)` when the cap is exhausted. + +--- + +## `subscriptions/listen` + +The 2026-07-28 revision delivers `tools/prompts/resources` `list_changed` and +`resources/updated` only on a `subscriptions/listen` stream the client opened — the +server never sends an un-requested notification type. + +**Server side.** Nothing to register: the serving entries handle `subscriptions/listen` +themselves. `createMcpHandler` returns +`.notify.{toolsChanged, promptsChanged, resourcesChanged, resourceUpdated(uri)}` typed +publish sugar over an in-process bus (supply your own `ServerEventBus` for multi-process +deployments). On stdio, `serveStdio` routes the pinned instance's existing +`send*ListChanged()` calls onto the active subscriptions automatically. The 2025-era +unsolicited delivery model is unchanged on legacy connections. + +**Client side.** `ClientOptions.listChanged` keeps working: on a 2026-07-28 connection +the SDK auto-opens a `subscriptions/listen` stream whose filter is the intersection of +the configured sub-options and the server-advertised `listChanged` capabilities, so the +same handlers fire on every published change. `client.listen(filter)` opens a stream +explicitly. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, request +`notifications/resources/updated` via the `resourceSubscriptions` field of the listen +filter instead. + +**Graceful close.** When the server closes the listen stream deliberately (entry +`close()`/shutdown), it sends the empty `subscriptions/listen` JSON-RPC result before +closing the stream; `McpSubscription.closed` resolves `'graceful'`. A stream close +without a result resolves `'remote'` and indicates an unexpected disconnect — re-listen +if you still want events. + +--- + +## `Mcp-Param-*` and standard headers (SEP-2243) + +On a 2026-07-28 connection over Streamable HTTP, `Client.callTool()` mirrors tool +arguments designated with `x-mcp-header` in the tool's `inputSchema` into +`Mcp-Param-{Name}` HTTP request headers (Base64-sentinel-encoded where needed), and +`createMcpHandler` rejects a `tools/call` whose `Mcp-Param-*` headers are missing for a +present body value, malformed, or disagree with the body — `400 Bad Request` with +JSON-RPC `-32020` (`HeaderMismatch`). The Streamable HTTP transport also emits the +`Mcp-Name` standard header on every modern-enveloped request, and `createMcpHandler` +validates the SEP-2243 standard headers (`MCP-Protocol-Version`, `Mcp-Method`, +`Mcp-Name`) against the body on the modern path with the same rejection. + +**Modern-era exception** to the `SdkHttpError` mapping: on a modern-enveloped request, +an HTTP `400` whose body is a well-formed JSON-RPC error response addressed to the +pending request id is delivered in-band as a `ProtocolError` (so the `-32020` recovery +retry can fire). Legacy-era exchanges and generic HTTP failures still surface as +`SdkHttpError`. + +Additive options: `CallToolRequestOptions.toolDefinition` (pass the tool definition +directly so mirroring and output-schema validation run without a prior `tools/list`), +`TransportSendOptions.headers` (per-request HTTP headers; reserved standard/auth header +names are skipped). Browser clients skip mirroring (dynamically named headers cannot be +statically allow-listed for credentialed CORS). + +--- + +## Cache fields and cache hints + +The 2026-07-28 revision requires `ttlMs` and `cacheScope` on the cacheable results. +When serving that revision, the SDK always emits both fields, defaulting to `ttlMs: 0` +and `cacheScope: 'private'` (the most conservative policy). To advertise a real cache +policy, set `ServerOptions.cacheHints` (per-operation) or `cacheHint` on a +`registerResource` metadata object; resolution is per field, most-specific author first. +2025-era responses never carry these fields. + +--- + +## Tasks: deprecated wire 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 inferred +types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, +`GetTask*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, +`TaskAugmentedRequestParams`), the task members of the request/result/notification union +types, the `tasks` capability key, `isTaskAugmentedRequestParams`, and +`RELATED_TASK_META_KEY`. All are now `@deprecated` (importable wire vocabulary only; +removable at the major version that drops 2025-era support). + +Task methods are excluded from the typed method maps: `RequestMethod` / `RequestTypeMap` +/ `ResultTypeMap` / `NotificationTypeMap` have no `tasks/*` or +`notifications/tasks/status` entries, so the method-keyed overloads of `request()`, +`ctx.mcpReq.send()`, `setRequestHandler()`, `setNotificationHandler()` reject task +methods at compile time. `ResultTypeMap['tools/call']` is plain `CallToolResult` (no +`| CreateTaskResult`); same for `sampling/createMessage` and `elicitation/create`. Where +task interop is genuinely required, use the explicit-schema custom-method form +(`request({ method: 'tasks/get', params }, GetTaskResultSchema)`). Inbound `tasks/*` +requests → `-32601`. + +The experimental tasks **interception** layer is removed entirely — see +[upgrade-to-v2.md › Experimental tasks interception removed](./upgrade-to-v2.md#experimental-tasks-interception-removed). + +--- + +## Appendix: 2025-era vs 2026-era behavior matrix + +| Axis | 2025-era (2024-10-07 … 2025-11-25) | 2026-07-28 | +| --- | --- | --- | +| Server HTTP entry | `*StreamableHTTPServerTransport` | `createMcpHandler` (`legacy: 'stateless'` also serves 2025) | +| Server stdio entry | `server.connect(new StdioServerTransport())` | `serveStdio(factory)` (also serves 2025 unless `legacy: 'reject'`) | +| Client connect | `initialize` handshake | `server/discover` probe (`versionNegotiation`) | +| Client identity | `getClientCapabilities()` / `getClientVersion()` (initialize-scoped) | `ctx.mcpReq.envelope` (per request) | +| Server→client requests | `ctx.mcpReq.elicitInput` / `requestSampling`, instance `createMessage()` etc. | `return inputRequired(...)` from handler | +| Change notifications | unsolicited `list_changed` / `resources/updated` | `subscriptions/listen` stream | +| Client cancellation (Streamable HTTP) | POST `notifications/cancelled` | close the request's SSE response stream | +| `ctx.mcpReq.log()` level filter | session-scoped `logging/setLevel` | per-request `_meta.logLevel` envelope key (absent = opt-out) | +| `400` JSON-RPC error body | `SdkHttpError` | `ProtocolError` (in-band) | +| Era-mismatched spec method (outbound) | n/a | `SdkError(MethodNotSupportedByProtocolVersion)` | diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md new file mode 100644 index 0000000000..c08c2b8867 --- /dev/null +++ b/docs/migration/upgrade-to-v2.md @@ -0,0 +1,1029 @@ +--- +name: migrate-v1-to-v2 +description: Migrate MCP TypeScript SDK code from v1 (@modelcontextprotocol/sdk) to v2 (@modelcontextprotocol/core, /client, /server). Use when a user asks to migrate, upgrade, or port their MCP TypeScript code from v1 to v2. +--- + +# Upgrading from v1.x to v2 + +This guide covers upgrading from `@modelcontextprotocol/sdk` (v1.x) to the v2 packages. +It is written for shell-capable agents and humans alike: run the codemod first, then +work through the manual sections for what the codemod can't rewrite. + +If you are already on v2 and want to adopt the **2026-07-28 protocol revision**, see +[support-2026-07-28.md](./support-2026-07-28.md) instead. + +## TL;DR — quick path + +1. **Prerequisites.** Node.js 20+ and ESM (`"type": "module"` or `.mts`). v2 ships ESM + only; CommonJS callers must use dynamic `import()`. +2. **Run the codemod.** + ```bash + npx @modelcontextprotocol/codemod@alpha v1-to-v2 ./src + ``` +3. **Grep for markers.** Anything the codemod recognized but could not safely rewrite is + marked in place: + ```bash + grep -rn '@mcp-codemod-error' . + ``` +4. **Type-check.** `tsc --noEmit` (or your build). Remaining errors map to the + [manual sections](#manual-changes-what-the-codemod-does-not-handle) below. +5. **Run your tests.** + +## Contents + +- [What the codemod handles](#what-the-codemod-handles) +- [What the codemod does NOT handle](#what-the-codemod-does-not-handle) +- [Manual changes](#manual-changes-what-the-codemod-does-not-handle) + - [Packaging & runtime](#packaging--runtime) + - [Imports & transports](#imports--transports) + - [Low-level protocol & handler context (`ctx`)](#low-level-protocol--handler-context-ctx) + - [Server registration API](#server-registration-api) + - [HTTP & headers](#http--headers) + - [Errors](#errors) + - [Auth](#auth) + - [Types & schemas](#types--schemas) + - [Behavioral changes](#behavioral-changes) +- [Enhancements](#enhancements) +- [Unchanged APIs](#unchanged-apis) +- [Need help?](#need-help) + +--- + +## What the codemod handles + +The codemod ([`@modelcontextprotocol/codemod`](../../packages/codemod/README.md)) +mechanically applies every rename whose mapping is fixed. The mappings are the +**source of truth** — they live in the codemod package and are not reproduced here: + +| Mapping | Source file | +| --- | --- | +| `@modelcontextprotocol/sdk/...` import paths → v2 packages | [`mappings/importMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts) | +| Symbol renames (`McpError` → `ProtocolError`, `JSONRPCError` → `JSONRPCErrorResponse`, …) | [`mappings/symbolMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/symbolMap.ts) | +| `setRequestHandler(Schema, …)` → `setRequestHandler('method/string', …)` | [`mappings/schemaToMethodMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts) | +| `extra.*` → `ctx.mcpReq.*` / `ctx.http?.*` property remap | [`mappings/contextPropertyMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts) | + +In addition the codemod: + +- Updates `package.json` dependencies (`@modelcontextprotocol/sdk` → the v2 packages + your imports actually use). +- Rewrites `.tool()` / `.prompt()` / `.resource()` to `registerTool` / `registerPrompt` + / `registerResource` and wraps `inputSchema` / `outputSchema` / `argsSchema` / + `uriSchema` raw Zod shapes with `z.object()`. +- Drops the result-schema argument from `client.request()` / `client.callTool()` for + spec methods. +- Rewrites standalone Zod-spec-schema usages: `XSchema.safeParse(v).success` → + `isSpecType.X(v)`; captured `const r = XSchema.safeParse(v)` → + `specTypeSchemas.X['~standard'].validate(v)` with `.success`/`.data`/`.error` + remapped; bare `XSchema` value uses → `specTypeSchemas.X`; `XSchema.parse()` is + rewritten and marked `@mcp-codemod-error` (the throw-on-invalid semantics differ). +- Renames `ErrorCode` → `ProtocolErrorCode` and routes the local-only members + (`RequestTimeout`, `ConnectionClosed`) to `SdkErrorCode`. +- Renames every `StreamableHTTPError` reference to `SdkHttpError` and adds the import + (constructor calls are marked for review — argument shape changed). +- Replaces `IsomorphicHeaders` with the Web Standard `Headers` type and drops the + import (a warning notes `Headers` uses `.get()`/`.set()`, not bracket access). +- Rewrites `SchemaInput` → `StandardSchemaWithJSON.InferInput`. +- Renames `RequestHandlerExtra` → `ServerContext` / `ClientContext` and the `extra` + parameter to `ctx`. +- Rewrites `vi.mock` / `jest.mock` and dynamic `import()` paths. +- Renames the `ResourceTemplate` **type** imported from `@modelcontextprotocol/sdk/types.js` + to `ResourceTemplateType` (the spec wire type). The `ResourceTemplate` URI-template + helper **class** from `server/mcp.js` keeps its name and is not renamed. +- Drops `@modelcontextprotocol/sdk/server/zod-compat.js` imports. + +## What the codemod does NOT handle + +Each of these maps to a manual section below. The codemod marks every site it +recognized but could not safely rewrite with an `@mcp-codemod-error` comment. + +- **Node 20 / ESM** — pre-flight, not a code rewrite. → [Packaging & runtime](#packaging--runtime) +- **`new Headers()` / `.get()` rewrite** — `IsomorphicHeaders` is renamed to `Headers` + and `extra.requestInfo?.headers[…]` is remapped to `ctx.http?.req?.headers[…]`, but + converting bracket access to `.get()` and wrapping plain objects with `new Headers()` + is manual. → [HTTP & headers](#http--headers) +- **`ctx.mcpReq.send()` schema-arg drop** — the codemod drops the schema arg from + `client.request()` / `client.callTool()` but leaves nested `ctx.mcpReq.send()` calls + alone. → [Low-level protocol](#low-level-protocol--handler-context-ctx) +- **OAuth error-class consolidation** — `instanceof InvalidGrantError` → `OAuthError` + + `OAuthErrorCode` is a judgment rewrite. → [Auth](#auth) +- **`SdkErrorCode` branch selection** — the codemod renames `StreamableHTTPError` → + `SdkHttpError`; deciding which `SdkErrorCode` branch a given catch should match is + judgment. → [Errors](#errors) +- **`.parse()` throw semantics** — the codemod rewrites `XSchema.parse()` to + `specTypeSchemas.X['~standard'].validate()` but `validate()` does not throw on + invalid input; the site is marked `@mcp-codemod-error`. → [Types & schemas](#types--schemas) +- **Behavioral adaptation** — list auto-aggregation, capability empties, lazy validator + compilation, output-schema validation rules. → [Behavioral changes](#behavioral-changes) + +--- + +## Manual changes (what the codemod does not handle) + +### Packaging & runtime + +The single `@modelcontextprotocol/sdk` package is split: + +| v1 | v2 | +| --- | --- | +| `@modelcontextprotocol/sdk` | `@modelcontextprotocol/client` (client implementation) | +| | `@modelcontextprotocol/server` (server implementation) | +| | `@modelcontextprotocol/core` (internal — never import directly) | +| Built-in HTTP framework support | `@modelcontextprotocol/node` / `@modelcontextprotocol/express` / `@modelcontextprotocol/hono` / `@modelcontextprotocol/fastify` | + +`@modelcontextprotocol/client` and `@modelcontextprotocol/server` both re-export shared +types from `@modelcontextprotocol/core`, so import types and error classes from +whichever package you already depend on. **Do not import from `@modelcontextprotocol/core` +directly.** + +The framework adapter packages declare their framework as a **peer dependency** +(`express`, `hono`, `fastify`); v1 shipped them as direct deps. The codemod adds the +`@modelcontextprotocol/*` packages your imports use, but does not add the framework +peer — install it explicitly (`pnpm add express` etc.). `@modelcontextprotocol/node` +depends on `@hono/node-server` at runtime (Node HTTP ↔ Web Standard conversion) but +does **not** require the `hono` framework — your package manager may emit a harmless +unmet-peer warning for `hono` (upstream `@hono/node-server` declares it). + +v2 requires **Node.js 20+** and ships **ESM only**. If your project uses CommonJS +(`require()`), either migrate to ESM or use dynamic `import()`. + +### Imports & transports + +The codemod rewrites every `@modelcontextprotocol/sdk/...` import path via +[`importMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts). +A few transports need a decision the codemod can't make: + +- **`StreamableHTTPServerTransport` → which runtime?** The codemod renames it to + `NodeStreamableHTTPServerTransport` from `@modelcontextprotocol/node`. If you deploy + to a web-standard runtime (Cloudflare Workers, Deno, Bun), use + `WebStandardStreamableHTTPServerTransport` from `@modelcontextprotocol/server` + instead. **Decision rule:** if your handler receives a Node `IncomingMessage` / + `ServerResponse`, use `@modelcontextprotocol/node`; if it receives a web-standard + `Request` and returns a `Response`, use `@modelcontextprotocol/server`. +- **stdio transports moved to a `./stdio` subpath.** Import `StdioClientTransport`, + `getDefaultEnvironment`, `DEFAULT_INHERITED_ENV_VARS`, and `StdioServerParameters` + from `@modelcontextprotocol/client/stdio`; import `StdioServerTransport` from + `@modelcontextprotocol/server/stdio`. The package root barrels do **not** export + these (the root entries are runtime-neutral so browser/Workers bundlers can consume + them). The stdio utilities `ReadBuffer`, `serializeMessage`, `deserializeMessage` + stay in the root barrel. + + ```typescript + // v1 + import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + // v2 + import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + ``` + +- **`SSEServerTransport`** is removed. Migrate to Streamable HTTP. A frozen v1 copy is + available from `@modelcontextprotocol/server-legacy/sse` as a temporary bridge. +- **`WebSocketClientTransport`** is removed (WebSocket is not a spec transport). Use + `StreamableHTTPClientTransport` for remote servers or `StdioClientTransport` for + local servers; the `Transport` interface is exported if you need a custom + implementation. +- **`InMemoryTransport`** is now exported from `@modelcontextprotocol/client` and + `@modelcontextprotocol/server` (both re-export it): + + ```typescript + // v1 + import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; + // v2 + import { InMemoryTransport } from '@modelcontextprotocol/server'; // or /client + ``` + +- **`EventStore`, `StreamId`, `EventId`** are exported from `@modelcontextprotocol/server` + only (v1 re-exported them alongside the transport from `sdk/server/streamableHttp.js`; + `@modelcontextprotocol/node` does not). +- **Server auth split.** Resource Server helpers (`requireBearerAuth`, + `mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`) + → `@modelcontextprotocol/express`. Authorization Server helpers (`mcpAuthRouter`, + `OAuthServerProvider`, `ProxyOAuthServerProvider`, `allowedMethods`, + `authenticateClient`, `metadataHandler`, `createOAuthMetadata`, + `authorizationHandler` / `tokenHandler` / `revocationHandler` / + `clientRegistrationHandler`) → `@modelcontextprotocol/server-legacy/auth` + (deprecated, frozen v1 copy); migrate AS to a dedicated IdP/OAuth library. `AuthInfo` + is now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`. + + The codemod's [`importMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts) + routes every `…/server/auth/**` deep path (including + `…/server/auth/middleware/{bearerAuth,allowedMethods,clientAuth}.js`, + `…/server/auth/handlers/*.js`, `…/server/auth/providers/proxyProvider.js`) to + `@modelcontextprotocol/server-legacy/auth`, and `…/server/express.js` / + `…/server/middleware/hostHeaderValidation.js` to `@modelcontextprotocol/express`. The + AS→`server-legacy` routing is conservative — re-point RS-only call sites + (`requireBearerAuth`, `mcpAuthMetadataRouter`) at `@modelcontextprotocol/express` by hand. + +### Low-level protocol & handler context (`ctx`) + +The second parameter to every request handler — previously the flat `RequestHandlerExtra` +object named `extra` — is now a structured **context** object named `ctx`. This is the +`ctx` that appears throughout the rest of this guide. + +The codemod renames the parameter and remaps property access via +[`contextPropertyMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts). +A few mappings need optional-chaining adjustment (the `http` group is `undefined` on +stdio): + +| v1 (`extra.*`) | v2 (`ctx.*`) | Note | +| --- | --- | --- | +| `extra.signal` | `ctx.mcpReq.signal` | | +| `extra.requestId` | `ctx.mcpReq.id` | | +| `extra._meta` | `ctx.mcpReq._meta` | | +| `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` | | +| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` | | +| `extra.sessionId` | `ctx.sessionId` | | +| `extra.authInfo` | `ctx.http?.authInfo` | optional — `undefined` on stdio | +| `extra.requestInfo` | `ctx.http?.req` | a standard Web `Request`; `ServerContext` only | +| `extra.closeSSEStream` | `ctx.http?.closeSSE` | `ServerContext` only | +| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` | `ServerContext` only | +| `extra.taskStore` / `taskId` / `taskRequestedTtl` | _removed_ | see [Experimental tasks](#experimental-tasks-interception-removed) | + +`BaseContext` is the common base; `ServerContext` and `ClientContext` extend it. +`ServerContext.mcpReq` adds convenience methods that replace calling `server.*` from +inside a handler: + +| `ctx.mcpReq.*` (new) | Replaces (inside a handler) | +| --- | --- | +| `ctx.mcpReq.log(level, data, logger?)` | `server.sendLoggingMessage(...)` — ⚠ **`@deprecated`**, see [§Deprecated in v2](#deprecated-in-v2-sep-2577) | +| `ctx.mcpReq.elicitInput(params, options?)` | `server.elicitInput(...)` | +| `ctx.mcpReq.requestSampling(params, options?)` | `server.createMessage(...)` — ⚠ **`@deprecated`**, see [§Deprecated in v2](#deprecated-in-v2-sep-2577) | + +#### Deprecated in v2 (SEP-2577) + +The roots, sampling, and logging subsystems are deprecated as of protocol version +2026-07-28 (SEP-2577). Everything below is **still fully functional in v2** and marked +`@deprecated` for removal in a later major; on a 2026-07-28 connection prefer the +[multi-round-trip `input_required` pattern](./support-2026-07-28.md#multi-round-trip-requests) +instead. + +- **Runtime APIs**: `Server.createMessage` / `listRoots` / `sendLoggingMessage`, + `McpServer.sendLoggingMessage`, `Client.setLoggingLevel` / `sendRootsListChanged`, and + the `ctx.mcpReq.log` / `ctx.mcpReq.requestSampling` handler-context helpers. +- **Capability fields**: the `roots`, `sampling`, and `logging` capability schema fields. +- **Type stacks**: the full Logging stack (`LoggingLevel`, `SetLevelRequest`, + `LoggingMessageNotification` and params), the full Sampling stack + (`CreateMessageRequest`/`Result`, `SamplingMessage`, `ModelPreferences`/`ModelHint`, + `ToolChoice`, `ToolUseContent`/`ToolResultContent`, the `includeContext` enum values), + and the full Roots stack (`Root`, `ListRootsRequest`/`Result`, + `RootsListChangedNotification`). +- **`registerClient`** (Dynamic Client Registration) — prefer Client ID Metadata + Documents per SEP-991. + +JSDoc/types only — wire behavior is unchanged and remains functional for at least the +twelve-month deprecation window. + +#### `setRequestHandler` / `setNotificationHandler` use method strings + +The low-level handler registration takes a **method string** instead of a Zod schema. +The codemod rewrites every spec-method registration via +[`schemaToMethodMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts). + +```typescript +// v1 +server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { ... }); +// v2 +server.setRequestHandler('tools/call', async (request, ctx) => { ... }); +``` + +**Custom (non-spec) methods** use the 3-arg form `(method, { params, result? }, handler)` +where `params` and `result` are any [Standard Schema](https://standardschema.dev). The +handler receives the parsed `params` directly (not the full request envelope); `_meta` +is at `ctx.mcpReq._meta`. The 3-arg notification handler is `(params, notification) => void`. + +```typescript +server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => { ... }); +``` + +#### `request()`, `ctx.mcpReq.send()`, and `callTool()` no longer require a schema for spec methods + +For **spec** methods, drop the result-schema argument; the SDK resolves it from the +method name. The codemod drops it from `client.request()` and `client.callTool()`; drop +it from `ctx.mcpReq.send()` by hand. + +```typescript +// v1 +import { CreateMessageResultSchema } from '@modelcontextprotocol/sdk/types.js'; +server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + const r = await extra.sendRequest({ method: 'sampling/createMessage', params: { ... } }, CreateMessageResultSchema); + return { content: [{ type: 'text', text: 'done' }] }; +}); + +// v2 +server.setRequestHandler('tools/call', async (request, ctx) => { + const r = await ctx.mcpReq.send({ method: 'sampling/createMessage', params: { ... } }); + return { content: [{ type: 'text', text: 'done' }] }; +}); +``` + +For **custom (non-spec)** methods, keep the result-schema argument: +`await client.request({ method: 'acme/search', params }, SearchResult)` — only drop the +schema when calling a spec method. + +The return type is inferred from the method name via `ResultTypeMap` (e.g. +`client.request({ method: 'tools/call', ... })` returns `Promise`). + +### Server registration API + +The deprecated variadic `.tool()`, `.prompt()`, `.resource()` are removed. Use +`registerTool` / `registerPrompt` / `registerResource` with an explicit config object. +The codemod converts the call shape and wraps `inputSchema` / `outputSchema` / +`argsSchema` / `uriSchema` raw shapes. + +```typescript +// v1 — raw shape, variadic +server.tool('greet', 'Greet a user', { name: z.string() }, async ({ name }) => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; +}); + +// v2 — config object, Standard Schema +server.registerTool( + 'greet', + { description: 'Greet a user', inputSchema: z.object({ name: z.string() }) }, + async ({ name }) => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; + } +); +``` + +`registerResource` requires a `metadata` argument — pass `{}` if you have none. + +#### Standard Schema objects (raw shapes deprecated) + +v2 expects schema objects implementing the [Standard Schema spec](https://standardschema.dev/) +for `inputSchema`, `outputSchema`, and `argsSchema`. Raw `{ field: z.string() }` shapes +are still **accepted via `@deprecated` overloads** on `registerTool`/`registerPrompt` +(auto-wrapped with `z.object()`), and `completable()` accepts any `StandardSchemaV1`; +prefer wrapping explicitly. Zod v4, ArkType, and Valibot all implement the spec. + +**Zod v3 is no longer supported** (v1 peer was `^3.25 || ^4.0`). Passing a Zod v3 schema +hard-errors with a pointer at `fromJsonSchema()`; Zod 4.0–4.1 schemas (which lack +`~standard.jsonSchema`) work via a bundled fallback with a one-time console warning. +Upgrade to `zod ^4.2.0` or use another Standard Schema library. + +```typescript +import * as z from 'zod/v4'; +server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, handler); + +// ArkType works too +import { type } from 'arktype'; +server.registerTool('greet', { inputSchema: type({ name: 'string' }) }, handler); + +// Raw JSON Schema via fromJsonSchema (validator defaults to runtime-appropriate choice) +import { fromJsonSchema } from '@modelcontextprotocol/server'; +server.registerTool('greet', { inputSchema: fromJsonSchema({ type: 'object', properties: { name: { type: 'string' } } }) }, handler); + +// No-parameter tools: z.object({}) +``` + +Removed Zod-specific helpers (the codemod marks each call site `@mcp-codemod-error`): +`schemaToJson` — use `fromJsonSchema()` from `@modelcontextprotocol/server` for raw JSON +Schema, or your schema library's native JSON-Schema conversion; `parseSchemaAsync` — use +your schema library's validation directly (e.g. Zod's `.safeParseAsync()`); +`getSchemaShape` / `getSchemaDescription` / `isOptionalSchema` / `unwrapOptionalSchema` +have no replacement (internal Zod introspection). `SchemaInput` → +`StandardSchemaWithJSON.InferInput` is rewritten mechanically by the codemod. The +internal `standardSchemaToJsonSchema` / `validateStandardSchema` helpers are **not** part +of the public surface — do not import them. + +### HTTP & headers + +Transport APIs and `ctx.http?.req?.headers` use the Web Standard `Headers` object +(`IsomorphicHeaders` is removed). `ctx.http?.req` is a standard Web `Request`. + +```typescript +// v1 +const transport = new StreamableHTTPClientTransport(url, { + requestInit: { headers: { Authorization: 'Bearer token' } } +}); +const sessionId = extra.requestInfo?.headers['mcp-session-id']; + +// v2 +const transport = new StreamableHTTPClientTransport(url, { + requestInit: { headers: new Headers({ Authorization: 'Bearer token' }) } +}); +const sessionId = ctx.http?.req?.headers.get('mcp-session-id'); +const debug = new URL(ctx.http!.req!.url).searchParams.get('debug'); +``` + +`StreamableHTTPClientTransport` now **appends** any custom `requestInit.headers.Accept` +value to the spec-required `application/json, text/event-stream` (v1 let it replace +them). The required media types are always present; additional types are kept for +proxy/gateway routing. + +`hostHeaderValidation()` and `localhostHostValidation()` moved to +`@modelcontextprotocol/express`. The `(allowedHostnames: string[])` signature is the +same as every released v1.x — only the import path changes. Framework-agnostic helpers +(`validateHostHeader`, `localhostAllowedHostnames`, `hostHeaderValidationResponse`) are +in `@modelcontextprotocol/server`. + +### Errors + +The SDK now distinguishes three error kinds: + +1. **`ProtocolError`** (renamed from `McpError`) — protocol errors that cross the wire + as JSON-RPC error responses. Uses `ProtocolErrorCode` (renamed from `ErrorCode`). +2. **`SdkError`** — local SDK errors that never cross the wire. Uses `SdkErrorCode`. +3. **`SdkHttpError`** (extends `SdkError`) — HTTP transport errors with typed `.status` + and `.statusText`. + +The codemod renames `McpError` → `ProtocolError`, `ErrorCode` → `ProtocolErrorCode` +(routing `RequestTimeout` / `ConnectionClosed` to `SdkErrorCode`), and +`StreamableHTTPError` → `SdkHttpError`. After the codemod runs, your `instanceof` +checks already name the v2 classes — what's left is choosing which `SdkErrorCode` / +class to match per scenario: + +| Scenario | v1 | v2 | +| --- | --- | --- | +| Request timeout | `McpError` + `ErrorCode.RequestTimeout` | `SdkError` + `SdkErrorCode.RequestTimeout` | +| Connection closed | `McpError` + `ErrorCode.ConnectionClosed` | `SdkError` + `SdkErrorCode.ConnectionClosed` | +| Capability not supported | `new Error(...)` | `SdkError` + `SdkErrorCode.CapabilityNotSupported` | +| Not connected | `new Error('Not connected')` | `SdkError` + `SdkErrorCode.NotConnected` | +| Response result fails schema | raw `ZodError` | `SdkError` + `SdkErrorCode.InvalidResult` | +| Invalid params (server response) | `McpError` + `ErrorCode.InvalidParams` | `ProtocolError` + `ProtocolErrorCode.InvalidParams` | +| HTTP transport error | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttp*` | +| Failed to open SSE stream | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpFailedToOpenStream` | +| 401 after re-auth (circuit break) | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpAuthentication` | +| `SSEClientTransport.send()` 401 after re-auth | `UnauthorizedError` | `SdkHttpError` + `SdkErrorCode.ClientHttpAuthentication` | +| 403 `insufficient_scope` after step-up retry cap | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpForbidden` | +| Unexpected content type | `StreamableHTTPError` | `SdkError` + `SdkErrorCode.ClientHttpUnexpectedContent` | +| Session termination failed | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpFailedToTerminateSession` | + +```typescript +// v1 +if (error instanceof McpError && error.code === ErrorCode.RequestTimeout) { ... } +if (error instanceof StreamableHTTPError) { console.log('HTTP status:', error.code); } + +// v2 +import { SdkError, SdkHttpError, SdkErrorCode, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/client'; +if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) { ... } +if (error instanceof SdkHttpError) { + console.log('HTTP status:', error.status, error.statusText); + switch (error.code) { + case SdkErrorCode.ClientHttpAuthentication: + case SdkErrorCode.ClientHttpForbidden: + case SdkErrorCode.ClientHttpFailedToOpenStream: + case SdkErrorCode.ClientHttpNotImplemented: + break; + } +} +``` + +`StreamableHTTPError` is removed. + +#### `SdkErrorCode` enum (complete) + +| Code | When thrown | +| --- | --- | +| `NotConnected` | Transport is not connected | +| `AlreadyConnected` | Transport is already connected | +| `NotInitialized` | Protocol is not initialized | +| `CapabilityNotSupported` | Required capability is not supported | +| `RequestTimeout` | Request timed out waiting for response | +| `ConnectionClosed` | Connection was closed | +| `SendFailed` | Failed to send message | +| `InvalidResult` | Response result failed local schema validation | +| `UnsupportedResultType` | A 2026-era response carried an unrecognized `resultType` | +| `InputRequiredRoundsExceeded` | Multi-round-trip auto-fulfilment hit `maxRounds` | +| `ListPaginationExceeded` | No-arg `list*()` aggregate walk hit `listMaxPages` | +| `MethodNotSupportedByProtocolVersion` | Outbound spec method does not exist on the negotiated protocol version | +| `EraNegotiationFailed` | `connect()` could not negotiate a protocol era (probe failed / no overlap) | +| `ClientHttpNotImplemented` | HTTP POST request failed | +| `ClientHttpAuthentication` | Server returned 401 after re-authentication | +| `ClientHttpForbidden` | Server returned 403 `insufficient_scope` after step-up retry cap | +| `ClientHttpUnexpectedContent` | Unexpected content type in HTTP response | +| `ClientHttpFailedToOpenStream` | Failed to open SSE stream | +| `ClientHttpFailedToTerminateSession` | Failed to terminate session | + +#### Typed `ProtocolError` subclasses + +`ResourceNotFoundError` (carries `.uri`) and `MissingRequiredClientCapabilityError` +(carries `data.requiredCapabilities`) are new typed `ProtocolError` subclasses. +`resources/read` for an unknown URI now answers `-32602` on every protocol revision +(v1.x already emitted `-32602`; an interim `-32002` from earlier v2 alphas is mapped at +the encode seam). `ProtocolErrorCode.ResourceNotFound` (`-32002`) stays importable as +receive-tolerated vocabulary — accept both `-32602` and `-32002` from peers. +`ProtocolError.fromError(code, message, data)` reconstructs the typed subclass from +code + data alone, so it works across bundle boundaries where `instanceof` doesn't. + +### Auth + +#### OAuth error consolidation + +The individual OAuth error classes are replaced with a single `OAuthError` + `OAuthErrorCode`. +The `OAUTH_ERRORS` constant is removed. The codemod does not rewrite `instanceof` checks +on these classes — switch on `error.code` instead. + +| v1 class | v2 equivalent | +| --- | --- | +| `InvalidRequestError` | `OAuthError` + `OAuthErrorCode.InvalidRequest` | +| `InvalidClientError` | `OAuthError` + `OAuthErrorCode.InvalidClient` | +| `InvalidGrantError` | `OAuthError` + `OAuthErrorCode.InvalidGrant` | +| `UnauthorizedClientError` | `OAuthError` + `OAuthErrorCode.UnauthorizedClient` | +| `UnsupportedGrantTypeError` | `OAuthError` + `OAuthErrorCode.UnsupportedGrantType` | +| `InvalidScopeError` | `OAuthError` + `OAuthErrorCode.InvalidScope` | +| `AccessDeniedError` | `OAuthError` + `OAuthErrorCode.AccessDenied` | +| `ServerError` | `OAuthError` + `OAuthErrorCode.ServerError` | +| `TemporarilyUnavailableError` | `OAuthError` + `OAuthErrorCode.TemporarilyUnavailable` | +| `UnsupportedResponseTypeError` | `OAuthError` + `OAuthErrorCode.UnsupportedResponseType` | +| `UnsupportedTokenTypeError` | `OAuthError` + `OAuthErrorCode.UnsupportedTokenType` | +| `InvalidTokenError` | `OAuthError` + `OAuthErrorCode.InvalidToken` | +| `MethodNotAllowedError` | `OAuthError` + `OAuthErrorCode.MethodNotAllowed` | +| `TooManyRequestsError` | `OAuthError` + `OAuthErrorCode.TooManyRequests` | +| `InvalidClientMetadataError` | `OAuthError` + `OAuthErrorCode.InvalidClientMetadata` | +| `InsufficientScopeError` | `OAuthError` + `OAuthErrorCode.InsufficientScope` ¹ | +| `InvalidTargetError` | `OAuthError` + `OAuthErrorCode.InvalidTarget` | +| `CustomOAuthError` | `new OAuthError(customCode, message)` | + +¹ Unrelated to the new transport-layer `InsufficientScopeError` (SEP-2350) exported from +`@modelcontextprotocol/client`, which carries an RFC 6750 challenge from the resource +server and extends `OAuthClientFlowError`, **not** `OAuthError`. Do not rewrite that one. + +```typescript +// v1 +if (error instanceof InvalidClientError) { ... } +// v2 +import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/client'; +if (error instanceof OAuthError && error.code === OAuthErrorCode.InvalidClient) { ... } +``` + +A frozen copy of the v1 classes (and `mcpAuthRouter`) is available from +`@modelcontextprotocol/server-legacy/auth` during migration. + +#### `AuthProvider` — non-OAuth bearer auth and the widened `authProvider` option + +The transport `authProvider` option is widened to `AuthProvider | OAuthClientProvider`. +**`AuthProvider`** is a new minimal interface — `{ token(): Promise; +onUnauthorized?(ctx): Promise }` — for static-token / non-OAuth bearer auth. +Transports call `token()` before every request and `onUnauthorized()` on 401 (then retry +once). Existing `OAuthClientProvider` implementations need no changes — transports adapt +them internally via the new `adaptOAuthProvider()` export. Also exported: +`isOAuthClientProvider()` (type guard) and `handleOAuthUnauthorized()` (the standard +OAuth `onUnauthorized` behavior, for composing your own adapter). + +#### OAuth client flow — behavioral changes + +- **Resolved scope passed to DCR (SEP-835).** `auth()` now computes the resolved scope + once (WWW-Authenticate → PRM `scopes_supported` → `clientMetadata.scope`) and passes + it to **both** the DCR POST body and the authorization request. `registerClient()` + gained an optional `scope` parameter that overrides `clientMetadata.scope` in the + registration body. +- **OAuth error on HTTP 200.** `exchangeAuthorization()` / `refreshAuthorization()` now + throw `OAuthError` when the AS returns HTTP 200 with a JSON `{error: ...}` body (e.g. + GitHub). v1 surfaced this as a Zod parse failure on the tokens schema. +- **Metadata discovery falls through on 502.** `discoverAuthorizationServerMetadata()` + treats `502 Bad Gateway` like 4xx — fall through to the next candidate URL instead of + throwing (fixes path-aware discovery behind reverse proxies). Other 5xx still throw. + +#### OAuth client flow errors (new) + +The OAuth client flow now throws dedicated classes from `@modelcontextprotocol/client` +(all extend `OAuthClientFlowError`, **not** `OAuthError` — `auth()`'s `OAuthError` retry +path will not catch them): + +| Throw site | v2 class | +| --- | --- | +| `registerClient()` rejected by AS (⚠ `@deprecated` — see [§Deprecated in v2](#deprecated-in-v2-sep-2577)) | `RegistrationRejectedError` (`status`, `body`, `submittedMetadata`) | +| Token-exchange / refresh / `fetchToken` / Cross-App grant on a non-`https:` token endpoint | `InsecureTokenEndpointError` (`tokenEndpoint`) | +| RFC 9207 `iss` mismatch / RFC 8414 §3.3 issuer-echo mismatch | `IssuerMismatchError` (`kind`, `expected`, `received`) | +| Transport 403 `insufficient_scope` with `onInsufficientScope: 'throw'`, or default mode without an `OAuthClientProvider` | `InsufficientScopeError` (`requiredScope`, `resourceMetadataUrl`, `errorDescription`) | +| `auth()` callback leg: discovery resolves a different AS than the recorded redirect target | `AuthorizationServerMismatchError` (`recordedIssuer`, `currentIssuer`) | + +#### `auth()` options are now `AuthOptions` + +The inline options object on `auth()` is now the named `AuthOptions` type. New fields: +`iss?: string` (the form-urldecoded `iss` from the authorization callback — pass it +alongside `authorizationCode` for RFC 9207 validation), `skipIssuerMetadataValidation?: +boolean` (security-weakening opt-out of the RFC 8414 §3.3 issuer-echo check), and +`forceReauthorization?: boolean` (skip the refresh-token branch — set by the transport's +step-up path; hosts driving step-up themselves set it under the same condition). + +#### Authorization-server mix-up defense (RFC 9207 / RFC 8414 §3.3) — action required + +`transport.finishAuth()` and `auth()` now validate `iss` from the authorization callback +against the issuer recorded from validated AS metadata. A mismatched `iss` throws +`IssuerMismatchError` before the code is exchanged regardless of advertised support; a +**missing** `iss` throws only when the AS advertised +`authorization_response_iss_parameter_supported: true`. + +Pass the callback URL's `URLSearchParams` so the SDK can read `iss` alongside `code`. +The SDK does **not** validate `state`; compare it yourself before calling `finishAuth`: + +```typescript +const params = new URL(callbackUrl).searchParams; +if (params.get('state') !== expectedState) throw new Error('state mismatch'); +await transport.finishAuth(params); // SDK reads `code` + `iss` +``` + +`transport.finishAuth(code, iss)` remains supported. Do **not** display `error` / +`error_description` / `error_uri` from a callback that failed `iss` validation — those +values are attacker-controlled in a mix-up attack. + +`discoverAuthorizationServerMetadata()` now rejects metadata whose `issuer` does not +exactly match the URL it was fetched for (RFC 8414 §3.3). Set +`skipIssuerMetadataValidation: true` only as a temporary workaround for a known-misconfigured AS. + +(`@modelcontextprotocol/server-legacy` AS implementers: `mcpAuthRouter()` now advertises +`authorization_response_iss_parameter_supported: true` by default and the bundled +authorize handler appends `iss` to every redirect issued via `res.redirect(...)` on the +supplied `res`. If you emit `Location` another way, append `params.issuer` as `iss` +yourself; if your callback is issued by an upstream AS you proxy to, set +`authorizationResponseIssParameterSupported = false` so the metadata does not over-claim.) + +#### Dynamic Client Registration defaults (SEP-837, SEP-2207) + +`auth()` now resolves `provider.clientMetadata` once via `resolveClientMetadata()` and +applies defaults to the DCR body: `grant_types` defaults to +`['authorization_code', 'refresh_token']`; `application_type` is derived from +`redirect_uris` (loopback / custom URI scheme → `'native'`, else `'web'`). A field you +set explicitly is never overwritten. The `grant_types` default applies to the DCR body +only — it does **not** drive the `offline_access` / `prompt=consent` augmentation on the +authorize request; statically-registered and CIMD clients that want that augmentation +must set `clientMetadata.grant_types` explicitly. Non-interactive providers (no +`redirectUrl`) get no `grant_types` default. Direct `registerClient()` callers (⚠ +`@deprecated` — see [§Deprecated in v2](#deprecated-in-v2-sep-2577)) wanting the same +defaults pass `resolveClientMetadata(provider)` as `clientMetadata`. DCR +rejection now throws `RegistrationRejectedError` (carrying `status`, `body`, +`submittedMetadata`). + +#### Token endpoint must use TLS (SEP-2207) + +`exchangeAuthorization()`, `refreshAuthorization()`, `fetchToken()`, and the Cross-App +Access helpers throw `InsecureTokenEndpointError` when the token endpoint is not +`https:` (loopback `localhost` / `127.0.0.1` / `::1` exempt). `auth()` surfaces this on +every path including refresh — switch any plain-`http:` AS on a non-loopback host to +TLS; there is no opt-out. Storage confidentiality of `refresh_token` remains your +`saveTokens()` implementation's responsibility. + +#### Scope step-up on `403 insufficient_scope` (SEP-2350) + +`StreamableHTTPClientTransport` accepts `onInsufficientScope: 'reauthorize' | 'throw'` +(default `'reauthorize'`). On `'reauthorize'` the transport re-authorizes with the +**union** of the previously-requested and challenged scope (`computeScopeUnion`); when +that union strictly exceeds the current token's granted scope (`isStrictScopeSuperset`), +the SDK bypasses the refresh-token branch and forces a fresh authorization request. On +`'throw'` the transport raises `InsufficientScopeError` and does not re-authorize — set +this for `client_credentials` / m2m clients where re-authorization can't widen scope, or +to gate the consent prompt behind UX. Step-up retries are hard-capped per send +(`maxStepUpRetries`, default 1). With a non-OAuth [`AuthProvider`](#authprovider--non-oauth-bearer-auth-and-the-widened-authprovider-option), +a `403 insufficient_scope` now throws `InsufficientScopeError` instead of the previous +`SdkHttpError(ClientHttpNotImplemented)`. The GET listen-stream open path applies the +same handling as the POST send path. + +#### Credentials bound to the issuing authorization server (SEP-2352) + +`auth()` stamps an `issuer` field onto every value it passes to `saveTokens()` / +`saveClientInformation()` and threads `{ issuer }` as the `ctx` argument to those +methods plus `tokens()` / `clientInformation()`. On read, a stored value whose `issuer` +names a different AS is treated as `undefined` and the flow re-registers / re-authorizes. +**Round-trip the stored object verbatim and you're protected** — single-slot storage +works. To hold credentials for several authorization servers at once, key your storage +on `ctx.issuer` (treat **`ctx === undefined` as "return the most-recently-saved token +set"** — the transport's per-request `Authorization: Bearer` read calls `tokens()` with +no `ctx`). New TypeScript-only aliases `StoredOAuthTokens` / `StoredOAuthClientInformation` +add an optional `issuer?: string` field on top of the wire types. + +`OAuthClientProvider.saveAuthorizationServerUrl()` / `authorizationServerUrl()` are +`@deprecated` (still written for back-compat, never read by the SDK). The bundled +`ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider`, and +`CrossAppAccessProvider` gain `expectedIssuer?: string` and no longer define +`saveClientInformation()`. Implement `discoveryState()` / `saveDiscoveryState()` so the +callback leg can verify it is exchanging the code at the same AS the redirect targeted; +without it the SDK `console.warn`s once per callback (`discoveryState` must persist with +the same durability as `codeVerifier`). + +#### Conformance obligations for `OAuthClientProvider` implementers + +The SDK enforces every authorization MUST that lands in SDK code. The following live in +**your** implementation and the SDK structurally cannot enforce them: + +- **Round-trip the `issuer` stamp** on persisted credentials (SEP-2352). Persist the + value verbatim from `saveTokens` / `saveClientInformation` and return it verbatim. +- **Pass `expectedIssuer`** when constructing static-credential providers (SEP-2352). +- **Keep refresh tokens confidential in storage** (SEP-2207) — OS keychain or + encrypted-at-rest store, never `localStorage` / plain files / logs. +- **Extract `iss` from the callback URL** and pass it to `finishAuth` (SEP-2468); when + `IssuerMismatchError` is thrown, do not render the callback's `error*` values. +- **Set `application_type` correctly** when overriding the heuristic (SEP-837). +- **Track cross-request step-up failures yourself** (SEP-2350) — `maxStepUpRetries` is + per request; per-session backoff is host state. +- **Resource-server operators: do not advertise `offline_access`** in `WWW-Authenticate` + `scope` or PRM `scopes_supported` (SEP-2207). + +### Types & schemas + +#### Zod `*Schema` constants are no longer public API + +The Zod schemas (`CallToolResultSchema`, `ListToolsResultSchema`, …) that v1 exported +from `types.js` are **not** part of the v2 public surface. They live in the internal +core barrel only; `@modelcontextprotocol/client` and `@modelcontextprotocol/server` do +not re-export them. + +If you used a `*Schema` constant for **runtime validation** (not just as a `request()` +argument), replace with `isSpecType` / `specTypeSchemas`: + +```typescript +// v1 +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +if (CallToolResultSchema.safeParse(value).success) { ... } + +// v2: keyed type predicate +import { isSpecType } from '@modelcontextprotocol/client'; +if (isSpecType.CallToolResult(value)) { ... } +const blocks = mixed.filter(isSpecType.ContentBlock); + +// v2: or get the StandardSchemaV1Sync validator object directly +import { specTypeSchemas } from '@modelcontextprotocol/client'; +const result = specTypeSchemas.CallToolResult['~standard'].validate(value); +``` + +`isSpecType` and `specTypeSchemas` are keyed by `SpecTypeName` — a literal union of +every named type in the MCP spec — so you get autocomplete and a compile error on typos. +`specTypeSchemas.X` is a `StandardSchemaV1Sync` (`validate()` is synchronous). +The pre-existing `isCallToolResult(value)` guard still works. + +**`specTypeSchemas.X` is `StandardSchemaV1`, not `ZodType`.** Zod-specific composition +methods — `.extend()`, `.pick()`, `.omit()`, `.merge()`, `.shape`, `.passthrough()`, +`.parseAsync()` — will not compile on a `specTypeSchemas` entry. The codemod emits an +`@mcp-codemod-error` warning at every such site; define your own schema (Zod, ArkType, +…) instead of extending the SDK's. The Zod-specific `AnySchema` / `SchemaOutput` types +from `…/zod-compat.js` are removed — replace with `StandardSchemaV1` / +`StandardSchemaV1.InferOutput` (the codemod's removal message says the same). + +The role-aggregate unions (`ClientRequest`, `ServerResult`, `ServerRequest`, +`ClientResult`, `ClientNotification`, `ServerNotification`) and the typed-method maps +(`RequestMethod`, `RequestTypeMap`, `ResultTypeMap`, `NotificationTypeMap`) no longer +include task vocabulary; the deprecated `Task*` types remain importable on their own. + +#### Removed type aliases + +| Removed | Replacement | +| --- | --- | +| `JSONRPCError` | `JSONRPCErrorResponse` | +| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | +| `isJSONRPCError` | `isJSONRPCErrorResponse` | +| `isJSONRPCResponse` (deprecated in v1) | `isJSONRPCResultResponse` ² | +| `ResourceReference` / `ResourceReferenceSchema` | `ResourceTemplateReference` / `ResourceTemplateReferenceSchema` | +| `IsomorphicHeaders` | Web Standard `Headers` | +| `RequestHandlerExtra` | `ServerContext` / `ClientContext` / `BaseContext` | +| `ResourceTemplate` (the spec wire **type** from `sdk/types.js`) | `ResourceTemplateType` ³ | + +² v2 introduces a **new** `isJSONRPCResponse` with corrected semantics — it matches +**both** result and error responses. v1's `isJSONRPCResponse` only matched results. To +preserve v1 behavior, rename to `isJSONRPCResultResponse` (the codemod does this). + +³ The `ResourceTemplate` URI-template helper **class** (from `sdk/server/mcp.js`) is +**unchanged** — keep `new ResourceTemplate(...)` as-is. Only the like-named spec wire +type from `types.js` was renamed to `ResourceTemplateType` to resolve the v1 collision; +the codemod scopes the rename to imports from `sdk/types.js` only. + +All other **type** symbols from `@modelcontextprotocol/sdk/types.js` retain their +original names — import them from `@modelcontextprotocol/client` or +`@modelcontextprotocol/server`. + +#### JSON Schema 2020-12 posture (SEP-1613, SEP-2106) + +The default validator supports **JSON Schema 2020-12 only**. On Node it is now `Ajv2020` +instead of draft-07 `Ajv`; the Cloudflare Workers default was already 2020-12. Schemas +declaring a different `$schema` are rejected with `Error("…unsupported dialect…")`. + +`CallToolResult.structuredContent` is widened from `{ [k: string]: unknown }` to +`unknown` (SEP-2106 lifts the `type:"object"` root restriction). The presence check is +`!== undefined`, not falsy (`null` / `0` / `false` / `""` are legal values now). External +`$ref` is not dereferenced (unchanged from v1; Ajv throws `MissingRefError` at compile, +surfaced per-tool on `callTool`). + +| v1 pattern | Mechanical fix | +| --- | --- | +| `result.structuredContent.` / `result.structuredContent?.` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '' in sc) { sc. }` | +| `if (!result.structuredContent)` | `if (result.structuredContent === undefined)` | +| relying on default `Ajv` being draft-07 | `new AjvJsonSchemaValidator(new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }))` (import `Ajv`, `addFormats`, `AjvJsonSchemaValidator` from `…/validators/ajv`) | +| draft-07 idioms via `fromJsonSchema(schema)` | `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — the `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema`-authored schemas | +| `outputSchema` / `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` | + +A tool may now register an `outputSchema` whose root is `type:"array"`, `type:"string"`, +etc.; toward 2025-era clients the codec wraps it in a `{result:…}` envelope, and toward +every era a non-object `structuredContent` with no `text` block of its own gets a +`JSON.stringify(...)` `text` block auto-appended. See [support-2026-07-28.md › Per-era wire codecs](./support-2026-07-28.md#per-era-wire-codecs) for how the codec applies these per era. + +### Behavioral changes + +These are runtime-behavior changes that may affect tests and assertions; no source +rewrite required unless noted. + +#### Error-shape changes (every era) + +- **Unknown / disabled tool calls now reject** with `ProtocolError(-32602 InvalidParams)` + instead of resolving `CallToolResult{isError: true}`. v1 callers that checked + `result.isError` for an unknown tool will get an unhandled rejection — catch the + rejected promise instead. +- **In-flight request handlers are aborted on transport close** — `ctx.mcpReq.signal` + fires (v1 let them run to completion). `InMemoryTransport.close()` no longer + double-fires `onclose` on the initiating side. +- **`Protocol.request()` with an already-aborted signal** rejects with + `SdkError(SdkErrorCode.RequestTimeout, reason)` instead of throwing the raw + `signal.reason`, matching the in-flight-abort path. +- **OAuth discovery (`discoverOAuthProtectedResourceMetadata` / `discoverOAuthMetadata`, + transitively `auth()`) throws on fetch `TypeError`** (DNS failure, `ECONNREFUSED`, + invalid URL) in Node and Cloudflare Workers instead of swallowing it as a CORS miss + → `undefined`. The CORS-swallow remains browser-only. + +#### stdio transport + +- A configurable `maxBufferSize` (default **10 MB**) caps the stdio read buffer. A + single message that would push the buffer past the limit emits `onerror` and + **closes the connection** (v1 buffered unbounded). Configure via + `new StdioClientTransport({ ..., maxBufferSize })` / + `new StdioServerTransport(stdin, stdout, { maxBufferSize })`. +- `ReadBuffer.readMessage()` now **silently skips non-JSON stdout lines** instead of + throwing `SyntaxError` → `onerror`. Hot-reload tools (tsx, nodemon) that write debug + output to stdout no longer break the transport. Lines that parse as JSON but fail + JSON-RPC schema validation still throw. +- `StdioClientTransport` always sets `windowsHide: true` when spawning the server + process on Windows (previously Electron-only). Prevents stray console windows in + non-Electron Windows hosts. + +#### Client list methods + +- `listPrompts()`, `listResources()`, `listResourceTemplates()`, `listTools()` return + **empty results** when the server didn't advertise the corresponding capability, + instead of sending the request. Set `enforceStrictCapabilities: true` in `ClientOptions` + to restore the v1 throw. +- Called **without a `cursor`**, the same methods now **auto-aggregate every page** and + return `nextCursor: undefined`. Passing `{ cursor }` still fetches one page. Manual + pagination loops keep working (the first iteration returns everything); replace them + with the bare no-arg call. The walk is capped at `ClientOptions.listMaxPages` (default + 64); overrun throws `SdkError(ListPaginationExceeded)`. +- Output-schema validator compilation is now **lazy** — validators compile on the first + `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()`. + In v1, `listTools()` threw on an uncompilable `outputSchema`; now `listTools()` + succeeds and the compile failure surfaces when `callTool()` is invoked on the affected + tool, as `ProtocolError(InvalidParams, "Tool 'X' has an invalid outputSchema: …")`, + before the request is sent. Validation is never silently skipped. +- On a 2026-07-28 connection the cacheable verbs honour the server-stamped `ttlMs` / + `cacheScope` (SEP-2549) and may return a still-fresh cached entry without a round + trip. Per-call override: `{ cacheMode: 'refresh' | 'bypass' }`. New `ClientOptions`: + `cachePartition`, `defaultCacheTtlMs`. `ResponseCacheStore` gained `delete(key)`; + `InMemoryResponseCacheStore` is now bounded (`{ maxEntries }`, default 512). + +#### Server (Streamable HTTP transport) + +- Resumability behavior (SSE priming events, `closeSSE` / `closeStandaloneSSE` + 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. +- Session-ID mismatch still responds `404` with JSON-RPC `-32001` (`Session not found`), + unchanged from v1. This `-32001` is an SDK convention, not spec-assigned; client code + should key off the HTTP `404` status, not `-32001`. + +#### Server (deprecated accessors and app-factory Origin validation) + +- `Server.getClientCapabilities()`, `getClientVersion()`, `getNegotiatedProtocolVersion()` + are `@deprecated` but functional. On 2026-07-28 requests, prefer `ctx.mcpReq.envelope`. +- `createMcpExpressApp()` / `createMcpHonoApp()` / `createMcpFastifyApp()` with a + localhost-class `host` now also validate the `Origin` header by default. Browser-served + clients on a non-localhost origin need `allowedOrigins: [...]` (replaces the default + localhost allowlist; validation cannot be disabled for localhost binds). Requests + without an `Origin` header are unaffected; a present `Origin` that cannot be parsed + — including the opaque **`Origin: null`** sent by sandboxed iframes, `file://` pages, + and cross-origin redirects — is **rejected with 403** and cannot be allowlisted via + `allowedOrigins`. Framework-agnostic helpers + (`validateOriginHeader`, `localhostAllowedOrigins`, `originValidationResponse`) are in + `@modelcontextprotocol/server`; `@modelcontextprotocol/node` ships + `hostHeaderValidation` / `originValidation` request guards for plain `node:http`. + +#### Server (McpServer / Streamable HTTP behavior) + +- **Eager capability-handler install.** `McpServer` now installs list/read/call handlers + for every primitive capability declared in `ServerOptions.capabilities`, even with + zero registrations. `new McpServer(info, { capabilities: { tools: {} } })` with no + registered tools answers `tools/list` with `{ tools: [] }` instead of `-32601 Method + not found`. Low-level `Server` users remain responsible for registering handlers for + declared capabilities. +- **`WebStandardStreamableHTTPServerTransport` store-first `eventStore` semantics.** + Request-related events emitted after `closeSSE()` — and the final response when no + per-request stream is connected — are now persisted to the configured `eventStore` for + replay (v1 dropped them / threw `"No connection established"`). Without an + `eventStore`, the same condition surfaces via `onerror` and the request id is retired. +- **`registerResource` reserves the `cacheHint` config key.** It is validated + (`RangeError` on invalid values) and stripped from the resource's list metadata; v1 + passed it through verbatim as ordinary metadata. Untyped callers that previously + smuggled a `cacheHint` key through resource metadata should rename it. + +#### `ctx.mcpReq.log()` is request-related on every era + +`ctx.mcpReq.log()` now emits its `notifications/message` request-related (it rides the +in-flight exchange like progress) on every era. On a 2025-era sessionful Streamable HTTP +transport this moves handler-emitted logs from the standalone GET stream onto the +per-request POST response stream — a spec-conformance correction. The session-scoped +`logging/setLevel` filter applies as before on 2025-era connections. (On 2026-07-28 +requests, the per-request `_meta.logLevel` envelope key is the filter — see +[support-2026-07-28.md](./support-2026-07-28.md#serving-the-2026-07-28-revision).) + +#### Wire tightening (every era) + +- **`CallToolResult.content` is required at the wire boundary.** The `content.default([])` + affordance was removed. Tool handlers MUST include `content` (the TypeScript surface + always required it; `content: []` is fine). A handler result without it is rejected + with `-32602`. +- **Custom (3-arg) handlers receive `_meta`.** `setRequestHandler(method, {params}, handler)` + used to delete `params._meta` before validation; it now passes `_meta` through (minus + the reserved `io.modelcontextprotocol/*` envelope keys). If your params schema is + strict, add an optional `_meta` member. +- **`specTypeSchemas` validate the neutral model.** Result entries no longer accept + `resultType`; the validators for the 2025-only task message types and + `RequestMetaEnvelope` left the public set (`SpecTypeName` narrowed accordingly). +- **Sampling `hasTools` discriminant** now keys on `tools || toolChoice` (previously + `tools` only) when selecting the with-tools `CreateMessageResult` variant, on every + era. + +#### Experimental tasks interception removed + +The 2025-11 task side-channel through `Protocol` is removed (was always `@experimental`). +No mechanical migration; remove usages. Gone: `ProtocolOptions.tasks`, +`protocol.taskManager`, `RequestOptions.task` / `relatedTask`, `BaseContext.task`, +`assertTaskCapability` / `assertTaskHandlerCapability`, `*.experimental.tasks.*` +accessors and `Experimental{Client,Server,McpServer}Tasks`, `requestStream` / +`callToolStream` / `createMessageStream` / `elicitInputStream` and the `ResponseMessage` +types they yielded, `registerToolTask`, `ToolTaskHandler`, `TaskRequestHandler`, +`CreateTaskRequestHandler`, `TaskMessageQueue`, `InMemoryTaskMessageQueue`, +`BaseQueuedMessage` / `Queued*`, `CreateTaskServerContext`, `TaskServerContext`, +`TaskToolExecution`, `TaskStore`, `InMemoryTaskStore`, `CreateTaskOptions`, `isTerminal`, +and the `new McpServer(info, { taskStore, taskMessageQueue })` constructor option keys +(the codemod emits an action-required diagnostic at each — remove the option). + +The task **wire types** remain importable as `@deprecated` vocabulary for 2025-11-25 +interop — see [support-2026-07-28.md](./support-2026-07-28.md#tasks-deprecated-wire-vocabulary). + +#### Specification clarifications adopted (no SDK behavior change) + +The 2026-07-28 specification revision includes a number of documentation-only +clarifications recorded here so an audit of the revision's changelog against this guide +is complete; nothing in this list requires code changes: per-operation timeout guidance +removal (`RequestOptions.timeout` / `DEFAULT_REQUEST_TIMEOUT_MSEC` unchanged); stdio +shutdown wording; transports-as-bindings reframe; `resources/read` wording (the +`file://` path-sanitization MUST is server-author guidance — your handler must reject +traversal / symlink escapes itself); `PromptMessage` resource links (already in +`ContentBlock`); completion `ref/resource` URI templates; pagination empty-string +cursors (already passed through verbatim); sampling host-requirement docs; elicitation +statefulness wording; cosmetic schema/JSDoc sweeps. + +--- + +## Enhancements + +### Automatic JSON Schema validator selection by runtime + +The SDK auto-selects the validator: Node.js → AJV; Cloudflare Workers (workerd) → +`@cfworker/json-schema`. Cloudflare Workers users can remove explicit +`jsonSchemaValidator` configuration. You don't need to install `ajv`, `ajv-formats`, or +`@cfworker/json-schema` for the default path. To customize the built-in backend, import +the named class from the explicit subpath +(`@modelcontextprotocol/{client,server}/validators/ajv` or `…/cf-worker`) — importing +from a subpath means the corresponding peer dep must be in your `package.json`. + +### `Client.connect(transport, { prior })` — zero-round-trip connect + +Probe once, persist `client.getDiscoverResult()` (`JSON.stringify`), and feed it to +every worker as `client.connect(transport, { prior })` — 2026-07-28+ only. New exported +type `ConnectOptions` (extends `RequestOptions` with `prior?: DiscoverResult`). + +### Serving the 2026-07-28 revision + +`createMcpHandler`, `serveStdio`, `versionNegotiation`, multi-round-trip requests +(`requestState`), client cancellation via stream-close, `subscriptions/listen`, +`Mcp-Param-*` headers, and per-era wire codecs are covered in +**[support-2026-07-28.md](./support-2026-07-28.md)** — they are net-new in v2, not v1→v2 +breaks. + +--- + +## Unchanged APIs + +The following are unchanged between v1 and v2 (only the import path changed): + +- `Client` constructor and `connect`, `close`, and the typed verbs (`listTools`, + `listPrompts`, `listResources`, `readResource`, …) — note `callTool()` and `request()` + signatures changed (schema parameter dropped for spec methods). +- `McpServer` constructor, `server.connect(transport)`, `server.close()`. +- `StreamableHTTPClientTransport`, `SSEClientTransport` constructors and options. +- `StdioClientTransport` and `StdioServerTransport` — **import path moved** to the + `./stdio` subpath and gained an optional `maxBufferSize` ([Imports & transports](#imports--transports)). +- All TypeScript **type** definitions from `types.ts` (except the aliases listed under + [Removed type aliases](#removed-type-aliases)). +- Tool, prompt, and resource callback return types. + +> The `Server` (low-level) constructor and **most** of its methods are unchanged, but +> `setRequestHandler` / `setNotificationHandler` and `request()` signatures changed +> ([Low-level protocol](#low-level-protocol--handler-context-ctx)). The Zod `*Schema` +> constants are **not** part of the unchanged surface — they are no longer public +> ([Types & schemas](#types--schemas)). + +--- + +## Need help? + +- The codemod's [`@mcp-codemod-error`](../../packages/codemod/README.md) markers point + at every site it could not safely rewrite. +- The [FAQ](../faq.md) covers common v2 questions. +- Runnable [examples](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) + for every subsystem. +- Open an issue on [GitHub](https://github.com/modelcontextprotocol/typescript-sdk/issues). diff --git a/docs/server-quickstart.md b/docs/server-quickstart.md index b8d19e7e1c..0ed198be18 100644 --- a/docs/server-quickstart.md +++ b/docs/server-quickstart.md @@ -472,5 +472,5 @@ This isn't an error - it just means there are no current weather alerts for that Now that your server is running locally, here are some ways to go further: - [**Server guide**](./server.md) — Add resources, prompts, logging, error handling, and remote transports to your server. -- [**Example servers**](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server) — Browse runnable examples covering OAuth, streaming, sessions, and more. +- [**Example servers**](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) — Browse runnable examples covering OAuth, streaming, sessions, and more. - [**FAQ**](./faq.md) — Troubleshoot common errors (Zod version conflicts, transport issues, etc.). diff --git a/docs/server.md b/docs/server.md index 468bf0cb2a..38163bd4c8 100644 --- a/docs/server.md +++ b/docs/server.md @@ -16,7 +16,7 @@ Building a server takes three steps: The examples below use these imports. Adjust based on which features and transport you need: -```ts source="../examples/server/src/serverGuide.examples.ts#imports" +```ts source="../examples/guides/serverGuide.examples.ts#imports" import { randomUUID } from 'node:crypto'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; @@ -38,7 +38,7 @@ MCP supports two transport mechanisms (see [Transport layer](https://modelcontex Create a {@linkcode @modelcontextprotocol/node!streamableHttp.NodeStreamableHTTPServerTransport | NodeStreamableHTTPServerTransport} and connect it to your server: -```ts source="../examples/server/src/serverGuide.examples.ts#streamableHttp_stateful" +```ts source="../examples/guides/serverGuide.examples.ts#streamableHttp_stateful" const server = new McpServer({ name: 'my-server', version: '1.0.0' }); const transport = new NodeStreamableHTTPServerTransport({ @@ -50,23 +50,43 @@ await server.connect(transport); **Options:** Set `sessionIdGenerator` to a function (shown above) for stateful sessions. Set it to `undefined` for stateless mode (simpler, but does not support resumability). Set `enableJsonResponse: true` to return plain JSON instead of SSE streams. -For a complete server with sessions, logging, and CORS mounted on Express, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts). +For a complete server with sessions and the browser-client CORS recipe, see [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts). ### stdio For local, process-spawned integrations, use {@linkcode @modelcontextprotocol/server!server/stdio.StdioServerTransport | StdioServerTransport}: -```ts source="../examples/server/src/serverGuide.examples.ts#stdio_basic" +```ts source="../examples/guides/serverGuide.examples.ts#stdio_basic" const server = new McpServer({ name: 'my-server', version: '1.0.0' }); const transport = new StdioServerTransport(); await server.connect(transport); ``` +#### Serving the 2026-07-28 draft revision on stdio + +A hand-constructed stdio server speaks the 2025-era protocol it was written for: nothing about its wire behavior changes when you upgrade the SDK. Serving the 2026-07-28 draft revision goes through the connection-pinned `serveStdio` entry, which mirrors `createMcpHandler` for +long-lived connections — the entry owns the transport and the era decision, and one instance from your factory serves the era the client opened the connection with: + +```typescript +import { serveStdio } from '@modelcontextprotocol/server/stdio'; + +serveStdio(() => { + const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + // register tools/resources/prompts once — the same factory serves both eras + return server; +}); +``` + +Plain 2025 clients open with `initialize` and are served exactly as before; 2026-capable clients negotiate via `server/discover` and send each request with the per-request `_meta` envelope, and their connection is pinned to a 2026-era instance. Pass `legacy: 'reject'` to refuse +2025-era openings with the unsupported-protocol-version error. On 2026-pinned connections, read per-request client identity from `ctx.mcpReq.envelope` in your handlers rather than the connection-scoped accessors (see the [2026-07-28 support guide](./migration/support-2026-07-28.md) for details). A runnable +example lives at `examples/dual-era/server.ts`, with a two-legged client at `examples/dual-era/client.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. +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. -```ts source="../examples/server/src/serverGuide.examples.ts#instructions_basic" +```ts source="../examples/guides/serverGuide.examples.ts#instructions_basic" const server = new McpServer( { name: 'db-server', version: '1.0.0' }, { @@ -80,9 +100,13 @@ const server = new McpServer( Tools let clients invoke actions on your server — they are usually the main way LLMs call into your application (see [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) in the MCP overview). -Register a tool with {@linkcode @modelcontextprotocol/server!server/mcp.McpServer#registerTool | registerTool}. Provide an `inputSchema` (Zod) to validate arguments, and optionally an `outputSchema` for structured return values: +Register a tool with {@linkcode @modelcontextprotocol/server!server/mcp.McpServer#registerTool | registerTool}. Provide an `inputSchema` (Zod) to validate arguments, and optionally an `outputSchema` for structured return values. + +> On the 2026-07-28 draft serving path, a tool whose `inputSchema` carries an `x-mcp-header` annotation has that argument mirrored into an `Mcp-Param-{Name}` HTTP request header by conforming clients. `createMcpHandler` validates those headers before dispatch and rejects a +> `tools/call` whose `Mcp-Param-*` headers are missing for a present body value, malformed, or disagree with the body — `400 Bad Request` with JSON-RPC `-32020` (`HeaderMismatch`). `registerTool` warns at registration time when an `x-mcp-header` declaration violates the +> spec's constraints. The 2025-era serving paths and the low-level `Server` factory shape are unchanged. -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_basic" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_basic" server.registerTool( 'calculate-bmi', { @@ -104,21 +128,11 @@ server.registerTool( ); ``` -> [!NOTE] -> When defining a named type for `structuredContent`, use a `type` alias rather than an `interface`. Named interfaces lack implicit index signatures in TypeScript, so they aren't assignable to `{ [key: string]: unknown }`: -> -> ```ts -> type BmiResult = { bmi: number }; // assignable -> interface BmiResult { bmi: number } // type error -> ``` -> -> Alternatively, spread the value: `structuredContent: { ...result }`. - ### `ResourceLink` outputs Tools can return `resource_link` content items to reference large resources without embedding them, letting clients fetch only what they need: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_resourceLink" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_resourceLink" server.registerTool( 'list-files', { @@ -149,7 +163,7 @@ server.registerTool( Tools can include annotations that hint at their behavior — whether a tool is read-only, destructive, or idempotent. Annotations help clients present tools appropriately without changing execution semantics: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_annotations" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_annotations" server.registerTool( 'delete-file', { @@ -172,7 +186,7 @@ server.registerTool( Tools, prompts, resources, and resource templates can advertise `icons` that a client may render in its UI — the same field is also accepted on your server's `Implementation` info. Each icon needs a `src` (a URL or `data:` URI) and may add a `mimeType`, the `sizes` it suits (`"WxH"` strings, or `"any"` for scalable formats like SVG), and a `theme` (`light` or `dark`). Icons are passed straight through to the relevant list response, such as `tools/list`: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_icons" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_icons" server.registerTool( 'generate-chart', { @@ -200,7 +214,7 @@ server.registerTool( Return `isError: true` to report tool-level errors. The LLM sees these and can self-correct, unlike protocol-level errors which are hidden from it: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_errorHandling" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_errorHandling" server.registerTool( 'fetch-data', { @@ -232,11 +246,12 @@ If a handler throws instead of returning `isError`, the SDK catches the exceptio ## Resources -Resources expose read-only data — files, database schemas, configuration — that the host application can retrieve and attach as context for the model (see [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources) in the MCP overview). Unlike [tools](#tools), which the LLM invokes on its own, resources are application-controlled: the host decides which resources to fetch and how to present them. +Resources expose read-only data — files, database schemas, configuration — that the host application can retrieve and attach as context for the model (see [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources) in the MCP overview). Unlike +[tools](#tools), which the LLM invokes on its own, resources are application-controlled: the host decides which resources to fetch and how to present them. A static resource at a fixed URI: -```ts source="../examples/server/src/serverGuide.examples.ts#registerResource_static" +```ts source="../examples/guides/serverGuide.examples.ts#registerResource_static" server.registerResource( 'config', 'config://app', @@ -253,7 +268,7 @@ server.registerResource( Dynamic resources use {@linkcode @modelcontextprotocol/server!server/mcp.ResourceTemplate | ResourceTemplate} with URI patterns. The `list` callback lets clients discover available instances: -```ts source="../examples/server/src/serverGuide.examples.ts#registerResource_template" +```ts source="../examples/guides/serverGuide.examples.ts#registerResource_template" server.registerResource( 'user-profile', new ResourceTemplate('user://{userId}/profile', { @@ -281,13 +296,15 @@ server.registerResource( ``` > [!IMPORTANT] -> **Security note:** If a resource is backed by the filesystem (for example, a `file://` server or a template whose variables map onto file paths), the spec requires sanitizing any user-influenced path before use. Resolve the requested path and verify it stays within the intended root directory, rejecting traversal sequences such as `..` (including encoded forms) and symlinks that escape the root. Never pass template variables or client-supplied URIs to filesystem APIs unchecked. +> **Security note:** If a resource is backed by the filesystem (for example, a `file://` server or a template whose variables map onto file paths), the spec requires sanitizing any user-influenced path before use. Resolve the requested path and verify it stays within +> the intended root directory, rejecting traversal sequences such as `..` (including encoded forms) and symlinks that escape the root. Never pass template variables or client-supplied URIs to filesystem APIs unchecked. ## Prompts -Prompts are reusable templates that help structure interactions with models (see [Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) in the MCP overview). Use a prompt when you want to offer a canned interaction pattern that users invoke explicitly; use a [tool](#tools) when the LLM should decide when to call it. +Prompts are reusable templates that help structure interactions with models (see [Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) in the MCP overview). Use a prompt when you want to offer a canned interaction pattern that users invoke explicitly; use +a [tool](#tools) when the LLM should decide when to call it. -```ts source="../examples/server/src/serverGuide.examples.ts#registerPrompt_basic" +```ts source="../examples/guides/serverGuide.examples.ts#registerPrompt_basic" server.registerPrompt( 'review-code', { @@ -315,7 +332,7 @@ server.registerPrompt( Both prompts and resources can support argument completions. Wrap a field in the `argsSchema` with {@linkcode @modelcontextprotocol/server!server/completable.completable | completable()} to provide autocompletion suggestions: -```ts source="../examples/server/src/serverGuide.examples.ts#registerPrompt_completion" +```ts source="../examples/guides/serverGuide.examples.ts#registerPrompt_completion" server.registerPrompt( 'review-code', { @@ -344,19 +361,20 @@ server.registerPrompt( ## Logging > [!WARNING] -> MCP logging is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to stderr logging (STDIO servers) or OpenTelemetry. +> MCP logging is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate +> to stderr logging (STDIO servers) or OpenTelemetry. Logging lets your server send structured diagnostics — debug traces, progress updates, warnings — to the connected client as notifications (see [Logging](https://modelcontextprotocol.io/specification/latest/server/utilities/logging) in the MCP specification). Declare the `logging` capability, then call `ctx.mcpReq.log(level, data)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside any handler: -```ts source="../examples/server/src/serverGuide.examples.ts#logging_capability" +```ts source="../examples/guides/serverGuide.examples.ts#logging_capability" const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { logging: {} } }); ``` Then log from any handler: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_logging" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_logging" server.registerTool( 'fetch-data', { @@ -379,7 +397,7 @@ Progress notifications let a tool report incremental status updates during long- If the client includes a `progressToken` in the request `_meta`, send `notifications/progress` via `ctx.mcpReq.notify()` (from {@linkcode @modelcontextprotocol/server!index.BaseContext | BaseContext}): -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_progress" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_progress" server.registerTool( 'process-files', { @@ -414,11 +432,13 @@ server.registerTool( ## Trace context propagation -The MCP specification ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)) reserves the unprefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` for distributed trace context, as an exception to the usual `_meta` key prefix rule. When present, the values must follow the [W3C Trace Context](https://www.w3.org/TR/trace-context/) and [W3C Baggage](https://www.w3.org/TR/baggage/) formats. The SDK does not interpret these keys — `_meta` passes through untouched on any transport, including stdio. The key names are exported as `TRACEPARENT_META_KEY`, `TRACESTATE_META_KEY`, and `BAGGAGE_META_KEY`. +The MCP specification ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)) reserves the unprefixed `_meta` keys `traceparent`, `tracestate`, and `baggage` for distributed trace context, as an exception to the usual `_meta` key prefix rule. When +present, the values must follow the [W3C Trace Context](https://www.w3.org/TR/trace-context/) and [W3C Baggage](https://www.w3.org/TR/baggage/) formats. The SDK does not interpret these keys — `_meta` passes through untouched on any transport, including stdio. The key names are +exported as `TRACEPARENT_META_KEY`, `TRACESTATE_META_KEY`, and `BAGGAGE_META_KEY`. Read the caller's trace context from `ctx.mcpReq._meta` in a handler: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_traceContext" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_traceContext" server.registerTool( 'traced-operation', { @@ -442,18 +462,20 @@ To propagate context onward (for example on a server-initiated sampling request, ## Server-initiated requests -MCP is bidirectional — servers can send requests *to* the client during tool execution, as long as the client declares matching capabilities (see [Architecture](https://modelcontextprotocol.io/docs/learn/architecture) in the MCP overview). +MCP is bidirectional — servers can send requests _to_ the client during tool execution, as long as the client declares matching capabilities (see [Architecture](https://modelcontextprotocol.io/docs/learn/architecture) in the MCP overview). ### Sampling > [!WARNING] -> Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to calling LLM provider APIs directly from your server. +> Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to +> calling LLM provider APIs directly from your server. -Sampling lets a tool handler request an LLM completion from the connected client — the handler describes a prompt and the client returns the model's response (see [Sampling](https://modelcontextprotocol.io/docs/learn/client-concepts#sampling) in the MCP overview). Use sampling when a tool needs the model to generate or transform text mid-execution. +Sampling lets a tool handler request an LLM completion from the connected client — the handler describes a prompt and the client returns the model's response (see [Sampling](https://modelcontextprotocol.io/docs/learn/client-concepts#sampling) in the MCP overview). Use sampling +when a tool needs the model to generate or transform text mid-execution. Call `ctx.mcpReq.requestSampling(params)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside a tool handler: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_sampling" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_sampling" server.registerTool( 'summarize', { @@ -485,7 +507,7 @@ server.registerTool( ); ``` -For a full runnable example, see [`toolWithSampleServer.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/toolWithSampleServer.ts). +For a full runnable example, see [`sampling/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/sampling/server.ts). ### Elicitation @@ -499,7 +521,7 @@ Elicitation lets a tool handler request direct input from the user — form fiel Call `ctx.mcpReq.elicitInput(params)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside a tool handler: -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_elicitation" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_elicitation" server.registerTool( 'collect-feedback', { @@ -539,16 +561,19 @@ server.registerTool( ); ``` -For runnable examples, see [`elicitationFormExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/elicitationFormExample.ts) (form) and [`elicitationUrlExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/elicitationUrlExample.ts) (URL). +For runnable examples, see [`elicitation/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/elicitation/server.ts) (form + URL mode, both protocol eras) and +[`mrtr/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/mrtr/server.ts) (the secure `requestState` round-trip pattern). ### Roots > [!WARNING] -> Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to passing paths via tool parameters, resource URIs, or configuration. +> Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to +> passing paths via tool parameters, resource URIs, or configuration. -Roots let a tool handler discover the client's workspace directories — for example, to scope a file search or identify project boundaries (see [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) in the MCP overview). Call {@linkcode @modelcontextprotocol/server!server/server.Server#listRoots | server.server.listRoots()} (requires the client to declare the `roots` capability): +Roots let a tool handler discover the client's workspace directories — for example, to scope a file search or identify project boundaries (see [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) in the MCP overview). Call {@linkcode +@modelcontextprotocol/server!server/server.Server#listRoots | server.server.listRoots()} (requires the client to declare the `roots` capability): -```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_roots" +```ts source="../examples/guides/serverGuide.examples.ts#registerTool_roots" server.registerTool( 'list-workspace-files', { @@ -567,7 +592,7 @@ server.registerTool( For stateful multi-session HTTP servers, capture the `http.Server` from `app.listen()` so you can stop accepting connections, then close each session transport: -```ts source="../examples/server/src/serverGuide.examples.ts#shutdown_statefulHttp" +```ts source="../examples/guides/serverGuide.examples.ts#shutdown_statefulHttp" // Capture the http.Server so it can be closed on shutdown const httpServer = app.listen(3000); @@ -587,24 +612,26 @@ Calling {@linkcode @modelcontextprotocol/server!index.Transport#close | transpor For stdio servers, {@linkcode @modelcontextprotocol/server!server/mcp.McpServer#close | server.close()} is sufficient: -```ts source="../examples/server/src/serverGuide.examples.ts#shutdown_stdio" +```ts source="../examples/guides/serverGuide.examples.ts#shutdown_stdio" process.on('SIGINT', async () => { await server.close(); process.exit(0); }); ``` -For a complete multi-session server with shutdown handling, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts). +For a complete multi-session server with shutdown handling, see [`repl/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/repl/server.ts). ## Deployment ### DNS rebinding protection -Under normal circumstances, cross-origin browser restrictions limit what a malicious website can do to your localhost server. [DNS rebinding attacks](https://en.wikipedia.org/wiki/DNS_rebinding) get around those restrictions entirely by making the requests appear as same-origin, since the attacking domain resolves to localhost. Validating the host header on the server side protects against this scenario. **All localhost MCP servers should use DNS rebinding protection.** +Under normal circumstances, cross-origin browser restrictions limit what a malicious website can do to your localhost server. [DNS rebinding attacks](https://en.wikipedia.org/wiki/DNS_rebinding) get around those restrictions entirely by making the requests appear as same-origin, +since the attacking domain resolves to localhost. Validating the host header on the server side protects against this scenario. **All localhost MCP servers should use DNS rebinding protection.** -The recommended approach is to use {@linkcode @modelcontextprotocol/express!express.createMcpExpressApp | createMcpExpressApp()} (from `@modelcontextprotocol/express`) or {@linkcode @modelcontextprotocol/hono!hono.createMcpHonoApp | createMcpHonoApp()} (from `@modelcontextprotocol/hono`), which enable Host header validation by default: +The recommended approach is to use {@linkcode @modelcontextprotocol/express!express.createMcpExpressApp | createMcpExpressApp()} (from `@modelcontextprotocol/express`) or {@linkcode @modelcontextprotocol/hono!hono.createMcpHonoApp | createMcpHonoApp()} (from +`@modelcontextprotocol/hono`), which enable Host header validation by default: -```ts source="../examples/server/src/serverGuide.examples.ts#dnsRebinding_basic" +```ts source="../examples/guides/serverGuide.examples.ts#dnsRebinding_basic" // Default: DNS rebinding protection auto-enabled (host is 127.0.0.1) const app = createMcpExpressApp(); @@ -617,7 +644,7 @@ const appOpen = createMcpExpressApp({ host: '0.0.0.0' }); When binding to `0.0.0.0` / `::`, provide an allow-list of hosts: -```ts source="../examples/server/src/serverGuide.examples.ts#dnsRebinding_allowedHosts" +```ts source="../examples/guides/serverGuide.examples.ts#dnsRebinding_allowedHosts" const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['localhost', '127.0.0.1', 'myhost.local'] @@ -626,22 +653,27 @@ 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 -- [`examples/server/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server) — Full runnable server examples +- [`examples/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) — Full runnable server examples - [Client guide](./client.md) — Building MCP clients with this SDK - [MCP overview](https://modelcontextprotocol.io/docs/learn/architecture) — Protocol-level concepts: participants, layers, primitives -- [Migration guide](./migration.md) — Upgrading from previous SDK versions +- [Migration guide](./migration/index.md) — Upgrading from previous SDK versions - [FAQ](./faq.md) — Frequently asked questions and troubleshooting ### Additional examples -| Feature | Description | Example | -|---------|-------------|---------| -| Web Standard transport | Deploy on Cloudflare Workers, Deno, or Bun | [`honoWebStandardStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/honoWebStandardStreamableHttp.ts) | -| Session management | Per-session transport routing, initialization, and cleanup | [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) | -| Resumability | Replay missed SSE events via an event store | [`inMemoryEventStore.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/inMemoryEventStore.ts) | -| CORS | Expose MCP headers for browser clients | [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) | -| Multi-node deployment | Stateless, persistent-storage, and distributed routing patterns | [`examples/server/README.md`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/README.md#multi-node-deployment-patterns) | +| Feature | Description | Example | +| ---------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| Web Standard transport | Deploy on Cloudflare Workers, Deno, or Bun | [`hono/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/hono/server.ts) | +| Session management | Per-session transport routing, initialization, and cleanup | [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts) | +| Resumability | Replay missed SSE events via an event store | [`inMemoryEventStore.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/shared/src/inMemoryEventStore.ts) | +| CORS | Expose MCP headers for browser clients | [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts) | +| Multi-node deployment | Stateless, persistent-storage, and distributed routing patterns | [`examples/README.md`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/README.md#multi-node-deployment-patterns) | diff --git a/examples/CONTRIBUTING.md b/examples/CONTRIBUTING.md new file mode 100644 index 0000000000..a0ad23e880 --- /dev/null +++ b/examples/CONTRIBUTING.md @@ -0,0 +1,94 @@ +# Contributing an example + +Each `examples//` directory is a tiny `@mcp-examples/` workspace +package containing a `server.ts` / `client.ts` pair. The pair is a +self-verifying e2e test: the client connects, asserts results, and exits +non-zero on any mismatch. `pnpm run:examples` runs every story over its +configured transport × era legs and is part of the per-PR CI gate. + +## Typical shape + +Examples are **compiled documentation**. Every story shows the SDK transport +setup **inline** — no helper hides `serveStdio`, `createMcpHandler`, `Client`, +or transport construction. The duplication is the feature: when the public API +changes, 25 compile errors flag 25 doc pages. + +Only the part a reader is _not_ here to learn — argv parsing — is shared, via +`parseExampleArgs` / `check` / `siblingPath` from `@mcp-examples/shared` (a +workspace package, so it reads as scaffolding, not part of the example). The +demo OAuth provider and `InMemoryEventStore` live at the +`@mcp-examples/shared/auth` subpath so the args-only root barrel does not pull +better-auth/express/better-sqlite3 into every story. + +Most stories follow the skeleton below; deviate freely when the story calls for +it (HTTP-only auth, sessionful transports, framework adapters, etc.). + +### `server.ts` + +```ts +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; + +function buildServer(): McpServer { + const server = new McpServer({ name: '-example', version: '1.0.0' }); + // … register tools / resources / prompts here … + return server; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} +``` + +### `client.ts` + +```ts +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const { transport, url, era } = parseExampleArgs(); + +const client = new Client({ name: '-example-client', version: '1.0.0' }, { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } }); + +await client.connect(transport === 'stdio' ? new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] }) : new StreamableHTTPClientTransport(new URL(url))); + +// … example body — drive the server and assert with `check.*` … + +await client.close(); +``` + +The body uses top-level `await`. A `check.*` failure throws, Node prints the +error and exits 1; on success `client.close()` releases the last handle and +Node exits 0. `pnpm run:examples` reports PASS/FAIL from the exit code (a +timeout is reported as a hang — investigate it as a possible unclosed handle). + +## Import rules (lint-enforced) + +Stories may import from: + +- `@modelcontextprotocol/{server,client,node,express,hono}` and their published + subpath exports (e.g. `@modelcontextprotocol/server/stdio`) +- `@mcp-examples/shared` (args/assert) and `@mcp-examples/shared/auth` (demo OAuth + `InMemoryEventStore`) +- third-party packages a consumer would `npm install` + +Stories may **not** import from: + +- `@modelcontextprotocol/core` or `@modelcontextprotocol/core/*` (internal barrel) +- `@modelcontextprotocol/*/src/*` or `@modelcontextprotocol/*/dist/*` (deep paths) +- `@modelcontextprotocol/test-helpers` +- any relative path that hides the SDK transport setup behind a shared helper + +`@mcp-examples/shared` itself must never import from a story package (one-way). diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000000..9e1441fff2 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,172 @@ +# MCP TypeScript SDK examples + +One **story** per directory. Every story is a runnable, self-verifying client/server pair: `server.ts` is what you would deploy, `client.ts` is what a host would write — it connects, exercises the feature with the public client API, asserts results, and exits 0. CI runs every +pair over every transport it supports (`scripts/examples/run-examples.ts`); a non-zero exit fails the build. + +Each story is its own private workspace package (`@mcp-examples/`). Run any pair from the repo root: + +```bash +# stdio (the client spawns the server itself): +pnpm --filter @mcp-examples/ client + +# Streamable HTTP (two terminals): +pnpm --filter @mcp-examples/ server -- --http --port 3000 +pnpm --filter @mcp-examples/ client -- --http http://127.0.0.1:3000/mcp +``` + +Add `-- --legacy` to the client command for the 2025-era handshake. + +## Start here + +| Story | What it teaches | +| ------------------------------------- | ------------------------------------------------------------------------ | +| [`tools/`](./tools/README.md) | Register tools, infer input/output schemas, call them, structured output | +| [`prompts/`](./prompts/README.md) | Prompts + argument completion | +| [`resources/`](./resources/README.md) | Static + templated resources, list/read | +| [`dual-era/`](./dual-era/README.md) | One factory, both protocol eras, both transports | + +## Feature stories + +| Story | What it teaches | Transports | Era | +| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------ | -------------- | +| [`mrtr/`](./mrtr/README.md) | Multi-round-trip write-once tool, secure `requestState` | stdio + http | modern | +| [`subscriptions/`](./subscriptions/README.md) | `subscriptions/listen`: `client.listen()` + auto-open, `handler.notify` / `ServerEventBus` | stdio + http | modern | +| [`streaming/`](./streaming/README.md) | In-flight progress, logging, cancellation | stdio + http | dual | +| [`elicitation/`](./elicitation/README.md) | Elicitation (form + URL mode), both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual | +| [`sampling/`](./sampling/README.md) | Tool that requests LLM sampling from the client, both eras: push-style on 2025, `inputRequired` on 2026 | stdio + http | dual | +| [`stickynotes/`](./stickynotes/README.md) | "Real app" capstone: tools mutate state, a resource per note, listChanged, elicitation-confirmed clear | stdio + http | dual | +| [`caching/`](./caching/README.md) | `cacheHints` stamping on cacheable results (2026-07-28) | stdio + http | modern | +| [`gateway/`](./gateway/README.md) | `connect({ prior })` — probe once, zero-round-trip connect for every worker (gateway pattern) | http | modern | +| [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | dual | +| [`schema-validators/`](./schema-validators/README.md) | ArkType, Valibot, Zod, and `outputSchema` | stdio + http | dual | +| [`custom-version/`](./custom-version/README.md) | `supportedProtocolVersions` / version negotiation | stdio + http | legacy | +| [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | dual | +| [`legacy-routing/`](./legacy-routing/README.md) | `isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict modern entry on one port | http | dual (in-body) | +| [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | dual | +| [`oauth/`](./oauth/README.md) | OAuth `authorization_code`: in-repo AS (auto-consent) + headless redirect-following client | http | dual | +| [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | dual | +| [`scoped-tools/`](./scoped-tools/README.md) | Per-tool scope on `createMcpHandler` — bearer-verify gate + handler-level `ctx.http?.authInfo` checks | http | modern | + +## HTTP hosting variants + +| Story | What it teaches | Transports | Era | +| --------------------------------------------------- | ------------------------------------------------------------- | ---------- | -------------- | +| [`stateless-legacy/`](./stateless-legacy/README.md) | `createMcpHandler` default posture (the minimal deployment) | http | dual (in-body) | +| [`json-response/`](./json-response/README.md) | `createMcpHandler({ responseMode: 'json' })` | http | modern | +| [`hono/`](./hono/README.md) | `createMcpHandler(...).fetch` on Hono / web-standard runtimes | http | dual | +| [`sse-polling/`](./sse-polling/README.md) | SEP-1699 SSE polling/resumption (sessionful 2025) | http | legacy | +| [`standalone-get/`](./standalone-get/README.md) | Standalone GET stream + `listChanged` push (sessionful 2025) | http | legacy | + +`dual (in-body)` = the client connects to both eras inside one runner invocation; the story demonstrates one server serving both side by side. + +## Excluded + +| Directory | What it is | Why not in CI | +| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| [`repl/`](./repl/README.md) | Fully-featured HTTP playground server + readline client | Interactive — `client.ts` reads from stdin. Run manually in two terminals. | +| [`guides/`](./guides/README.md) | Snippet collections synced into `docs/server.md` and `docs/client.md` | Typecheck-only; not a runnable pair. | +| `server-quickstart/`, `client-quickstart/` | Website-tutorial sources | External network / API key; typecheck-only. | +| `shared/` | Argv/assert scaffold (`parseExampleArgs`/`check`/`siblingPath`); demo OAuth provider + `InMemoryEventStore` at the `./auth` subpath | Not a story — imported by every story as scaffolding. | + +## Multi-node deployment patterns + +When deploying MCP servers in a horizontally scaled environment (multiple server instances), there are a few different options that can be useful for different use cases: + +- **Stateless mode** - no need to maintain state between calls. +- **Persistent storage mode** - state stored in a database; any node can handle a session. +- **Local state with message routing** - stateful nodes + pub/sub routing for a session. + +### Stateless mode + +To enable stateless mode, configure the `NodeStreamableHTTPServerTransport` with: + +```typescript +sessionIdGenerator: undefined; +``` + +``` +┌─────────────────────────────────────────────┐ +│ Client │ +└─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Load Balancer │ +└─────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────────┐ +│ MCP Server #1 │ │ MCP Server #2 │ +│ (Node.js) │ │ (Node.js) │ +└─────────────────┘ └─────────────────────┘ +``` + +### Persistent storage mode + +Configure the transport with session management, but use an external event store: + +```typescript +sessionIdGenerator: () => randomUUID(), +eventStore: databaseEventStore +``` + +``` +┌─────────────────────────────────────────────┐ +│ Client │ +└─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Load Balancer │ +└─────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────────┐ +│ MCP Server #1 │ │ MCP Server #2 │ +│ (Node.js) │ │ (Node.js) │ +└─────────────────┘ └─────────────────────┘ + │ │ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────┐ +│ Database (PostgreSQL) │ +│ │ +│ • Session state │ +│ • Event storage for resumability │ +└─────────────────────────────────────────────┘ +``` + +### Streamable HTTP with distributed message routing + +For scenarios where local in-memory state must be maintained on specific nodes, combine Streamable HTTP with pub/sub routing so one node can terminate the client connection while another node owns the session state. + +``` +┌─────────────────────────────────────────────┐ +│ Client │ +└─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Load Balancer │ +└─────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────────┐ +│ MCP Server #1 │◄───►│ MCP Server #2 │ +│ (Has Session A) │ │ (Has Session B) │ +└─────────────────┘ └─────────────────────┘ + ▲│ ▲│ + │▼ │▼ +┌─────────────────────────────────────────────┐ +│ Message Queue / Pub-Sub │ +│ │ +│ • Session ownership registry │ +│ • Bidirectional message routing │ +│ • Request/response forwarding │ +└─────────────────────────────────────────────┘ +``` + +## Backwards compatibility (Streamable HTTP ↔ legacy SSE) + +A client that needs to fall back from Streamable HTTP to the legacy HTTP+SSE transport (for servers that only implement the older transport) follows the [`connect_sseFallback`](../docs/client.md#sse-fallback-for-legacy-servers) recipe in the client guide — try +`StreamableHTTPClientTransport` first, fall back to `SSEClientTransport` on a 4xx. There is no runnable pair for this in `examples/` (the legacy SSE server transport is deprecated); the snippet in `guides/clientGuide.examples.ts` is the complete pattern. diff --git a/examples/bearer-auth/README.md b/examples/bearer-auth/README.md new file mode 100644 index 0000000000..a3d64c423d --- /dev/null +++ b/examples/bearer-auth/README.md @@ -0,0 +1,6 @@ +# bearer-auth + +Resource-server-only auth: `requireBearerAuth` from `@modelcontextprotocol/express` in front of `createMcpHandler`. The client asserts `401` + `WWW-Authenticate` without a token, and that the verified `authInfo` reaches the factory (`ctx.authInfo`) with +one. + +**HTTP-only** by definition. The full interactive OAuth set lives under `../oauth/` (run headlessly in CI via the demo AS's auto-consent mode). diff --git a/examples/bearer-auth/client.ts b/examples/bearer-auth/client.ts new file mode 100644 index 0000000000..334036cdb3 --- /dev/null +++ b/examples/bearer-auth/client.ts @@ -0,0 +1,31 @@ +/** + * Asserts a bare request is `401` with a `WWW-Authenticate` header, and that + * a request with `Authorization: Bearer demo-token` reaches the `whoami` tool + * with the verified `authInfo`. + */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const { url, era } = parseExampleArgs(); + +// Unauthenticated → 401 + WWW-Authenticate. +const unauth = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }) +}); +check.equal(unauth.status, 401); +check.match(unauth.headers.get('www-authenticate') ?? '', /Bearer/); + +// Authenticated → 200 and the tool sees the authInfo. Bearer auth is +// HTTP-layer and era-agnostic; the client honours `--legacy` via `era`. +const client = new Client( + { name: 'bearer-auth-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); +await client.connect(new StreamableHTTPClientTransport(new URL(url), { authProvider: { token: async () => 'demo-token' } })); + +const result = await client.callTool({ name: 'whoami', arguments: {} }); +check.equal(result.content?.[0]?.type === 'text' ? result.content[0].text : '', 'client=demo-client'); + +await client.close(); diff --git a/examples/bearer-auth/package.json b/examples/bearer-auth/package.json new file mode 100644 index 0000000000..88012bc865 --- /dev/null +++ b/examples/bearer-auth/package.json @@ -0,0 +1,28 @@ +{ + "name": "@mcp-examples/bearer-auth", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "dual", + "path": "/mcp", + "//": "Bearer auth + 401/WWW-Authenticate is HTTP-layer and era-agnostic; the client honours --legacy via the inline versionNegotiation branch." + } +} diff --git a/examples/bearer-auth/server.ts b/examples/bearer-auth/server.ts new file mode 100644 index 0000000000..189553869b --- /dev/null +++ b/examples/bearer-auth/server.ts @@ -0,0 +1,51 @@ +/** + * Minimal Resource-Server-only auth: `requireBearerAuth` + `OAuthTokenVerifier` + * in front of `createMcpHandler`. The verifier accepts a single static + * `demo-token`; the verified `authInfo` reaches the factory as `ctx.authInfo`. + * + * No Authorization Server here, and no metadata endpoints — see `examples/oauth/` + * for the full RS + AS discovery flow. HTTP-only by definition. + */ +import { parseExampleArgs } from '@mcp-examples/shared'; +import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; +import { createMcpExpressApp, requireBearerAuth } from '@modelcontextprotocol/express'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import type { AuthInfo, McpServerFactory } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const buildServer: McpServerFactory = ctx => { + const server = new McpServer({ name: 'bearer-auth-example', version: '1.0.0' }); + server.registerTool('whoami', { description: 'Returns the authenticated subject.', inputSchema: z.object({}) }, async () => ({ + content: [{ type: 'text', text: `client=${ctx.authInfo?.clientId ?? 'anon'}` }] + })); + return server; +}; + +const { port } = parseExampleArgs(); + +// Replace with JWT verification, RFC 7662 introspection, etc. +const staticTokenVerifier: OAuthTokenVerifier = { + async verifyAccessToken(token): Promise { + if (token !== 'demo-token') { + throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token'); + } + return { token, clientId: 'demo-client', scopes: ['mcp'], expiresAt: Math.floor(Date.now() / 1000) + 3600 }; + } +}; + +// Bearer auth is HTTP-layer (no stdio arm). The MCP handler is the canonical +// `createMcpHandler(buildServer)`; the Express auth middleware in front of it +// is the point of this story. +const handler = createMcpHandler(buildServer); + +const app = createMcpExpressApp(); +const auth = requireBearerAuth({ verifier: staticTokenVerifier, requiredScopes: ['mcp'] }); +// `requireBearerAuth` sets `req.auth`; `toNodeHandler` reads it and passes it +// to the factory as `ctx.authInfo`. +const node = toNodeHandler(handler); +app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); + +app.listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); +}); diff --git a/examples/caching/README.md b/examples/caching/README.md new file mode 100644 index 0000000000..840602a815 --- /dev/null +++ b/examples/caching/README.md @@ -0,0 +1,52 @@ +# caching + +`CacheableResult` freshness hints (protocol revision 2026-07-28). The server declares hints at two layers — a per-registration `cacheHint` on the resource and server-level `ServerOptions.cacheHints` — and the SDK resolves most-specific-author-first (handler-return fields would +take precedence over both) and stamps `ttlMs`/`cacheScope` on the wire toward modern clients only. The client honours the stamped values: a still-fresh held entry is served without a round trip. + +```bash +pnpm tsx examples/caching/client.ts +``` + +The client calls `listTools()` and `readResource()` twice each; the second of each pair is served from the response cache. The server exposes a `request-count` tool (how many `tools/list` requests reached it) and a `read-count` tool (how many times the resource handler ran), so the example asserts each counter is unchanged after the cache-served call and increments after `cacheMode: 'refresh'`. + +## `cacheMode` + +Per-call control on the cacheable verbs (`listTools()` / `listPrompts()` / `listResources()` / `listResourceTemplates()` / `readResource()`): + +```ts +await client.readResource({ uri: 'config://app' }); // 'use' (default): serve from cache if fresh +await client.readResource({ uri: 'config://app' }, { cacheMode: 'refresh' }); // always fetch, then re-store +await client.readResource({ uri: 'config://app' }, { cacheMode: 'bypass' }); // fetch; do not read or write the cache +``` + +A `list_changed` notification still evicts immediately regardless of TTL. + +## Custom store + +The default per-client `InMemoryResponseCacheStore` (bounded at 512 entries by default) is enough for most hosts. To back the cache with something persistent (Redis, KV, IndexedDB), implement the five-method `ResponseCacheStore` interface — the store is a dumb keyed-value carrier; freshness and partitioning are the client's job: + +```ts +import type { CacheEntry, CacheKey, CacheScope, ResponseCacheStore } from '@modelcontextprotocol/client'; + +class MyStore implements ResponseCacheStore { + async get(key: CacheKey): Promise { + /* read {value, stamp, expiresAt, scope} from your backend */ + } + async set(key: CacheKey, entry: { value: unknown; expiresAt?: number; scope?: CacheScope }): Promise { + /* write entry under key; return a monotonically-increasing stamp */ + } + async delete(key: CacheKey): Promise { + /* drop the single entry under key (no-op if absent) */ + } + async evict(method: string): Promise { + /* drop every entry whose key.method === method (across every partition) */ + } + async clear(): Promise { + /* drop everything */ + } +} + +const client = new Client({ name: 'host', version: '1.0.0' }, { responseCacheStore: new MyStore(), cachePartition: principalId }); +``` + +The SDK scopes every entry by the connected server's identity automatically — you do not encode server identity into `cachePartition` or the store key yourself. When one store backs several principals against the same server, set `ClientOptions.cachePartition` to a stable identity of the authorization context (e.g. the auth subject) so `'private'`-scoped entries are isolated per principal; `'public'`-scoped entries are shared within the connected server's namespace automatically. Note `serverInfo` is self-reported, so a server that deliberately impersonates another's `name`/`version` shares its `'public'` slot; the per-principal isolation holds regardless. diff --git a/examples/caching/client.ts b/examples/caching/client.ts new file mode 100644 index 0000000000..6a3c42cb60 --- /dev/null +++ b/examples/caching/client.ts @@ -0,0 +1,78 @@ +/** + * Reads the cache hints emitted on cacheable results (2026-07-28 connections + * only) and asserts the client honours them: a still-fresh cached entry is + * served without a round trip. + */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +interface Cacheable { + ttlMs?: number; + cacheScope?: 'public' | 'private'; +} + +async function callCount(client: Client, name: 'read-count' | 'request-count'): Promise { + const r = await client.callTool({ name }); + return Number((r.content[0] as { text: string }).text); +} + +const { transport, url, era } = parseExampleArgs(); + +const client = new Client( + { name: 'caching-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); + +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + +check.equal(client.getNegotiatedProtocolVersion(), '2026-07-28'); + +// The server stamps `tools/list` with `ttlMs: 30_000, cacheScope: 'public'`. +const tools = (await client.listTools()) as Cacheable & Awaited>; +check.equal(tools.ttlMs, 30_000); +check.equal(tools.cacheScope, 'public'); +// `request-count` proves the wire was reached exactly once. +check.equal(await callCount(client, 'request-count'), 1); + +// The second call is served from the response cache: the server-side +// `tools/list` counter is unchanged, and the result is a fresh copy of the +// held entry (so mutating it cannot reach the cache). +const toolsAgain = await client.listTools(); +check.deepEqual( + toolsAgain.tools.map(t => t.name), + tools.tools.map(t => t.name) +); +check.equal(await callCount(client, 'request-count'), 1); + +// `cacheMode: 'refresh'` always fetches and re-stores: the counter moves. +await client.listTools(undefined, { cacheMode: 'refresh' }); +check.equal(await callCount(client, 'request-count'), 2); + +const resources = (await client.listResources()) as Cacheable & Awaited>; +check.equal(resources.ttlMs, 5000); +check.equal(resources.cacheScope, 'public'); + +// `readResource`: the resource handler counts how many times it ran, and +// the `read-count` tool exposes that counter. +const read = (await client.readResource({ uri: 'config://app' })) as Cacheable & Awaited>; +check.equal(read.ttlMs, 60_000); +check.equal(read.cacheScope, 'private'); +check.equal(await callCount(client, 'read-count'), 1); + +// Within TTL, default `cacheMode: 'use'` → served from cache; the server +// handler does not run. +await client.readResource({ uri: 'config://app' }); +check.equal(await callCount(client, 'read-count'), 1); + +// `cacheMode: 'refresh'` always fetches and re-stores. +await client.readResource({ uri: 'config://app' }, { cacheMode: 'refresh' }); +check.equal(await callCount(client, 'read-count'), 2); + +// After the refresh the entry is fresh again — back to cache-served. +await client.readResource({ uri: 'config://app' }); +check.equal(await callCount(client, 'read-count'), 2); + +await client.close(); diff --git a/examples/caching/package.json b/examples/caching/package.json new file mode 100644 index 0000000000..173bbe05eb --- /dev/null +++ b/examples/caching/package.json @@ -0,0 +1,22 @@ +{ + "name": "@mcp-examples/caching", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "modern", + "//": "cacheHints (ttlMs / cacheScope on cacheable results) are emitted only toward 2026-era clients." + } +} diff --git a/examples/caching/server.ts b/examples/caching/server.ts new file mode 100644 index 0000000000..fe93e66e2c --- /dev/null +++ b/examples/caching/server.ts @@ -0,0 +1,102 @@ +/** + * Cache hints (`CacheableResult`, protocol revision 2026-07-28). + * + * The 2026-07-28 revision requires `ttlMs`/`cacheScope` on the cacheable + * result types (the list operations and `resources/read`). The values are + * resolved most-specific-author-first: + * + * 1. fields the handler returns on the result itself, + * 2. a per-registration `cacheHint` (here: the resource's read result), + * 3. the server-level per-operation `ServerOptions.cacheHints`, + * 4. the conservative defaults (`ttlMs: 0`, `cacheScope: 'private'`). + * + * The fields are emitted ONLY toward 2026-era clients — a 2025-era response + * is byte-for-byte unchanged. One binary, either transport. + */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; + +// Module-level (process-wide) counters so the values survive the stateless +// HTTP leg (fresh `buildServer()` per request) as well as stdio's single +// per-connection instance. The client asserts against these to prove a +// cache-served call never reached the server. +let readCount = 0; +let listCount = 0; + +function buildServer(): McpServer { + const server = new McpServer( + { name: 'caching-example', version: '1.0.0' }, + { + // Server-level per-operation hints: any list/read result that does not + // override a field gets these. + cacheHints: { + 'resources/list': { ttlMs: 5000, cacheScope: 'public' }, + 'tools/list': { ttlMs: 30_000, cacheScope: 'public' } + } + } + ); + + // A direct resource carrying a per-registration hint that wins for its + // own resources/read result. + server.registerResource( + 'app-config', + 'config://app', + { + mimeType: 'application/json', + description: 'Static application config (rarely changes)', + cacheHint: { ttlMs: 60_000, cacheScope: 'private' } + }, + async uri => { + readCount++; + return { contents: [{ uri: uri.href, mimeType: 'application/json', text: '{"feature":true}' }] }; + } + ); + + // A tool, so tools/list has something to cache. + server.registerTool('noop', { description: 'no-op' }, async () => ({ content: [{ type: 'text', text: 'ok' }] })); + + // Exposes the server-side `resources/read` invocation count so the client + // can assert that a cache-served call did not reach the wire. + server.registerTool('read-count', { description: 'Number of resources/read calls that reached this server' }, async () => ({ + content: [{ type: 'text', text: String(readCount) }] + })); + + // Exposes the server-side `tools/list` invocation count. + server.registerTool('request-count', { description: 'Number of tools/list requests that reached this server' }, async () => ({ + content: [{ type: 'text', text: String(listCount) }] + })); + + // Wrap the auto-generated `tools/list` handler so the example can prove a + // cache-served `listTools()` never reached the wire. `McpServer` registers + // the handler lazily on the first `registerTool()`; we re-seat it here so + // every dispatch increments `listCount` before delegating to the original. + // (Reaches the underlying request-handler map directly — there is no public + // wrapper hook; acceptable for an instrumentation example.) + const handlers = (server.server as unknown as { _requestHandlers: Map Promise> }) + ._requestHandlers; + const original = handlers.get('tools/list'); + if (original) { + handlers.set('tools/list', (...a) => { + listCount++; + return original(...a); + }); + } + + return server; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/client/README.md b/examples/client/README.md deleted file mode 100644 index 46f7c82c9c..0000000000 --- a/examples/client/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# MCP TypeScript SDK Examples (Client) - -This directory contains runnable MCP **client** examples built with `@modelcontextprotocol/client`. - -For server examples, see [`../server/README.md`](../server/README.md). For guided docs, see [`../../docs/client.md`](../../docs/client.md). - -## Running examples - -From the repo root: - -```bash -pnpm install -pnpm --filter @modelcontextprotocol/examples-client exec tsx src/simpleStreamableHttp.ts -``` - -Or, from within this package: - -```bash -cd examples/client -pnpm tsx src/simpleStreamableHttp.ts -``` - -Most clients expect a server to be running. Start one from [`../server/README.md`](../server/README.md) (for example `src/simpleStreamableHttp.ts` in `examples/server`). - -## Example index - -| Scenario | Description | File | -| --------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| Interactive Streamable HTTP client | CLI client that exercises tools/resources/prompts, notifications, and elicitation. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) | -| Backwards-compatible client (Streamable HTTP → SSE) | Tries Streamable HTTP first, falls back to legacy SSE on 4xx responses. | [`src/streamableHttpWithSseFallbackClient.ts`](src/streamableHttpWithSseFallbackClient.ts) | -| SSE polling client (legacy) | Polls a legacy HTTP+SSE server and demonstrates notification handling. | [`src/ssePollingClient.ts`](src/ssePollingClient.ts) | -| Parallel tool calls | Runs multiple tool calls in parallel. | [`src/parallelToolCallsClient.ts`](src/parallelToolCallsClient.ts) | -| Multiple clients in parallel | Connects multiple clients concurrently to the same server. | [`src/multipleClientsParallel.ts`](src/multipleClientsParallel.ts) | -| OAuth client (interactive) | OAuth-enabled client (dynamic registration, auth flow). | [`src/simpleOAuthClient.ts`](src/simpleOAuthClient.ts) | -| OAuth provider helper | Demonstrates reusable OAuth providers. | [`src/simpleOAuthClientProvider.ts`](src/simpleOAuthClientProvider.ts) | -| Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) | -| URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | - -## URL elicitation example (server + client) - -Run the server first: - -```bash -pnpm --filter @modelcontextprotocol/examples-server exec tsx src/elicitationUrlExample.ts -``` - -Then run the client: - -```bash -pnpm --filter @modelcontextprotocol/examples-client exec tsx src/elicitationUrlExample.ts -``` diff --git a/examples/client/eslint.config.mjs b/examples/client/eslint.config.mjs deleted file mode 100644 index 83b79879f6..0000000000 --- a/examples/client/eslint.config.mjs +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-check - -import baseConfig from '@modelcontextprotocol/eslint-config'; - -export default [ - ...baseConfig, - { - files: ['src/**/*.{ts,tsx,js,jsx,mts,cts}'], - rules: { - // Allow console statements in examples only - 'no-console': 'off' - } - } -]; diff --git a/examples/client/package.json b/examples/client/package.json deleted file mode 100644 index 57b329fd2d..0000000000 --- a/examples/client/package.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "@modelcontextprotocol/examples-client", - "private": true, - "version": "2.0.0-alpha.0", - "description": "Model Context Protocol implementation for TypeScript", - "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" - }, - "engines": { - "node": ">=20" - }, - "keywords": [ - "modelcontextprotocol", - "mcp" - ], - "scripts": { - "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "pnpm run build:esm && pnpm run build:cjs", - "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "start": "pnpm run server", - "server": "tsx watch --clear-screen=false scripts/cli.ts server", - "client": "tsx scripts/cli.ts client" - }, - "dependencies": { - "@modelcontextprotocol/client": "workspace:^", - "ajv": "catalog:runtimeShared", - "open": "^11.0.0", - "zod": "catalog:runtimeShared" - }, - "devDependencies": { - "@modelcontextprotocol/eslint-config": "workspace:^", - "@modelcontextprotocol/examples-shared": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "tsdown": "catalog:devTools" - } -} diff --git a/examples/client/src/customMethodExample.ts b/examples/client/src/customMethodExample.ts deleted file mode 100644 index a289af0a47..0000000000 --- a/examples/client/src/customMethodExample.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Custom (non-spec) method example: a client that sends `acme/search` and - * listens for `acme/searchProgress` notifications. - * - * Build `examples/server` first; this client spawns the server via stdio. - */ -import { Client } from '@modelcontextprotocol/client'; -import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -import { z } from 'zod/v4'; - -const SearchResult = z.object({ items: z.array(z.string()) }); -const SearchProgressParams = z.object({ stage: z.string(), pct: z.number() }); - -const client = new Client({ name: 'acme-search-client', version: '0.0.0' }); - -client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => { - console.log(`[progress] ${params.stage} ${Math.round(params.pct * 100)}%`); -}); - -await client.connect(new StdioClientTransport({ command: 'node', args: ['../server/dist/customMethodExample.js'] })); - -const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult); -console.log('items:', result.items); - -await client.close(); diff --git a/examples/client/src/elicitationUrlExample.ts b/examples/client/src/elicitationUrlExample.ts deleted file mode 100644 index 7c5cce2ee2..0000000000 --- a/examples/client/src/elicitationUrlExample.ts +++ /dev/null @@ -1,824 +0,0 @@ -// Run with: pnpm tsx src/elicitationUrlExample.ts -// -// This example demonstrates how to use URL elicitation to securely -// collect user input in a remote (HTTP) server. -// URL elicitation allows servers to prompt the end-user to open a URL in their browser -// to collect sensitive information. - -import { createServer } from 'node:http'; -import { createInterface } from 'node:readline'; - -import type { - ElicitRequest, - ElicitRequestURLParams, - ElicitResult, - ListToolsRequest, - OAuthClientMetadata, - ResourceLink -} from '@modelcontextprotocol/client'; -import { - Client, - getDisplayName, - ProtocolError, - ProtocolErrorCode, - StreamableHTTPClientTransport, - UnauthorizedError, - UrlElicitationRequiredError -} from '@modelcontextprotocol/client'; -import open from 'open'; - -import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; - -// Set up OAuth (required for this example) -const OAUTH_CALLBACK_PORT = 8090; // Use different port than auth server (3001) -const OAUTH_CALLBACK_URL = `http://localhost:${OAUTH_CALLBACK_PORT}/callback`; - -console.log('Getting OAuth token...'); -const clientMetadata: OAuthClientMetadata = { - client_name: 'Elicitation MCP Client', - redirect_uris: [OAUTH_CALLBACK_URL], - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - token_endpoint_auth_method: 'client_secret_post', - scope: 'mcp:tools' -}; -const oauthProvider = new InMemoryOAuthClientProvider(OAUTH_CALLBACK_URL, clientMetadata, (redirectUrl: URL) => { - console.log(`🌐 Opening browser for OAuth redirect: ${redirectUrl.toString()}`); - openBrowser(redirectUrl.toString()); -}); - -// Create readline interface for user input -const readline = createInterface({ - input: process.stdin, - output: process.stdout -}); -let abortCommand = new AbortController(); - -// Global client and transport for interactive commands -let client: Client | null = null; -let transport: StreamableHTTPClientTransport | null = null; -let serverUrl = 'http://localhost:3000/mcp'; -let sessionId: string | undefined; - -// Elicitation queue management -interface QueuedElicitation { - request: ElicitRequest; - resolve: (result: ElicitResult) => void; - reject: (error: Error) => void; -} - -let isProcessingCommand = false; -let isProcessingElicitations = false; -const elicitationQueue: QueuedElicitation[] = []; -let elicitationQueueSignal: (() => void) | null = null; -let elicitationsCompleteSignal: (() => void) | null = null; - -// Map to track pending URL elicitations waiting for completion notifications -const pendingURLElicitations = new Map< - string, - { - resolve: () => void; - reject: (error: Error) => void; - timeout: NodeJS.Timeout; - } ->(); - -async function main(): Promise { - console.log('MCP Interactive Client'); - console.log('====================='); - - // Connect to server immediately with default settings - await connect(); - - // Start the elicitation loop in the background - elicitationLoop().catch(error => { - console.error('Unexpected error in elicitation loop:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - }); - - // Short delay allowing the server to send any SSE elicitations on connection - await new Promise(resolve => setTimeout(resolve, 200)); - - // Wait until we are done processing any initial elicitations - await waitForElicitationsToComplete(); - - // Print help and start the command loop - printHelp(); - await commandLoop(); -} - -async function waitForElicitationsToComplete(): Promise { - // Wait until the queue is empty and nothing is being processed - while (elicitationQueue.length > 0 || isProcessingElicitations) { - await new Promise(resolve => setTimeout(resolve, 100)); - } -} - -function printHelp(): void { - console.log('\nAvailable commands:'); - console.log(' connect [url] - Connect to MCP server (default: http://localhost:3000/mcp)'); - console.log(' disconnect - Disconnect from server'); - console.log(' terminate-session - Terminate the current session'); - console.log(' reconnect - Reconnect to the server'); - console.log(' list-tools - List available tools'); - console.log(' call-tool [args] - Call a tool with optional JSON arguments'); - console.log(' payment-confirm - Test URL elicitation via error response with payment-confirm tool'); - console.log(' third-party-auth - Test tool that requires third-party OAuth credentials'); - console.log(' help - Show this help'); - console.log(' quit - Exit the program'); -} - -async function commandLoop(): Promise { - await new Promise(resolve => { - if (isProcessingElicitations) { - elicitationsCompleteSignal = resolve; - } else { - resolve(); - } - }); - - readline.question('\n> ', { signal: abortCommand.signal }, async input => { - isProcessingCommand = true; - - const args = input.trim().split(/\s+/); - const command = args[0]?.toLowerCase(); - - try { - switch (command) { - case 'connect': { - await connect(args[1]); - break; - } - - case 'disconnect': { - await disconnect(); - break; - } - - case 'terminate-session': { - await terminateSession(); - break; - } - - case 'reconnect': { - await reconnect(); - break; - } - - case 'list-tools': { - await listTools(); - break; - } - - case 'call-tool': { - if (args.length < 2) { - console.log('Usage: call-tool [args]'); - } else { - const toolName = args[1]!; - let toolArgs = {}; - if (args.length > 2) { - try { - toolArgs = JSON.parse(args.slice(2).join(' ')); - } catch { - console.log('Invalid JSON arguments. Using empty args.'); - } - } - await callTool(toolName, toolArgs); - } - break; - } - - case 'payment-confirm': { - await callPaymentConfirmTool(); - break; - } - - case 'third-party-auth': { - await callThirdPartyAuthTool(); - break; - } - - case 'help': { - printHelp(); - break; - } - - case 'quit': - case 'exit': { - await cleanup(); - return; - } - - default: { - if (command) { - console.log(`Unknown command: ${command}`); - } - break; - } - } - } catch (error) { - console.error(`Error executing command: ${error}`); - } finally { - isProcessingCommand = false; - } - - // Process another command after we've processed the this one - await commandLoop(); - }); -} - -async function elicitationLoop(): Promise { - while (true) { - // Wait until we have elicitations to process - await new Promise(resolve => { - if (elicitationQueue.length > 0) { - resolve(); - } else { - elicitationQueueSignal = resolve; - } - }); - - isProcessingElicitations = true; - abortCommand.abort(); // Abort the command loop if it's running - - // Process all queued elicitations - while (elicitationQueue.length > 0) { - const queued = elicitationQueue.shift()!; - console.log(`📤 Processing queued elicitation (${elicitationQueue.length} remaining)`); - - try { - const result = await handleElicitationRequest(queued.request); - queued.resolve(result); - } catch (error) { - queued.reject(error instanceof Error ? error : new Error(String(error))); - } - } - - console.log('✅ All queued elicitations processed. Resuming command loop...\n'); - isProcessingElicitations = false; - - // Reset the abort controller for the next command loop - abortCommand = new AbortController(); - - // Resume the command loop - if (elicitationsCompleteSignal) { - elicitationsCompleteSignal(); - elicitationsCompleteSignal = null; - } - } -} - -const ALLOWED_SCHEMES = new Set(['http:', 'https:']); - -async function openBrowser(url: string): Promise { - try { - const parsed = new URL(url); - if (!ALLOWED_SCHEMES.has(parsed.protocol)) { - console.error(`Refusing to open URL with unsupported scheme '${parsed.protocol}': ${url}`); - return; - } - } catch { - console.error(`Invalid URL: ${url}`); - return; - } - - try { - await open(url); - } catch { - console.log(`Please manually open: ${url}`); - } -} - -/** - * Enqueues an elicitation request and returns the result. - * - * This function is used so that our CLI (which can only handle one input request at a time) - * can handle elicitation requests and the command loop. - * - * @param request - The elicitation request to be handled - * @returns The elicitation result - */ -async function elicitationRequestHandler(request: ElicitRequest): Promise { - // If we are processing a command, handle this elicitation immediately - if (isProcessingCommand) { - console.log('📋 Processing elicitation immediately (during command execution)'); - return await handleElicitationRequest(request); - } - - // Otherwise, queue the request to be handled by the elicitation loop - console.log(`📥 Queueing elicitation request (queue size will be: ${elicitationQueue.length + 1})`); - - return new Promise((resolve, reject) => { - elicitationQueue.push({ - request, - resolve, - reject - }); - - // Signal the elicitation loop that there's work to do - if (elicitationQueueSignal) { - elicitationQueueSignal(); - elicitationQueueSignal = null; - } - }); -} - -/** - * Handles an elicitation request. - * - * This function is used to handle the elicitation request and return the result. - * - * @param request - The elicitation request to be handled - * @returns The elicitation result - */ -async function handleElicitationRequest(request: ElicitRequest): Promise { - const mode = request.params.mode; - console.log('\n🔔 Elicitation Request Received:'); - console.log(`Mode: ${mode}`); - - if (mode === 'url') { - return { - action: await handleURLElicitation(request.params as ElicitRequestURLParams) - }; - } else { - // Should not happen because the client declares its capabilities to the server, - // but being defensive is a good practice: - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unsupported elicitation mode: ${mode}`); - } -} - -/** - * Handles a URL elicitation by opening the URL in the browser. - * - * Note: This is a shared code for both request handlers and error handlers. - * As a result of sharing schema, there is no big forking of logic for the client. - * - * @param params - The URL elicitation request parameters - * @returns The action to take (accept, cancel, or decline) - */ -async function handleURLElicitation(params: ElicitRequestURLParams): Promise { - const url = params.url; - const elicitationId = params.elicitationId; - const message = params.message; - console.log(`🆔 Elicitation ID: ${elicitationId}`); // Print for illustration - - // Parse URL to show domain for security - let domain = 'unknown domain'; - try { - const parsedUrl = new URL(url); - domain = parsedUrl.hostname; - } catch { - console.error('Invalid URL provided by server'); - return 'decline'; - } - - // Example security warning to help prevent phishing attacks - console.log('\n⚠️ \u001B[33mSECURITY WARNING\u001B[0m ⚠️'); - console.log('\u001B[33mThe server is requesting you to open an external URL.\u001B[0m'); - console.log('\u001B[33mOnly proceed if you trust this server and understand why it needs this.\u001B[0m\n'); - console.log(`🌐 Target domain: \u001B[36m${domain}\u001B[0m`); - console.log(`🔗 Full URL: \u001B[36m${url}\u001B[0m`); - console.log(`\nℹ️ Server's reason:\n\n\u001B[36m${message}\u001B[0m\n`); - - // 1. Ask for user consent to open the URL - const consent = await new Promise(resolve => { - readline.question('\nDo you want to open this URL in your browser? (y/n): ', input => { - resolve(input.trim().toLowerCase()); - }); - }); - - // 2. If user did not consent, return appropriate result - if (consent === 'no' || consent === 'n') { - console.log('❌ URL navigation declined.'); - return 'decline'; - } else if (consent !== 'yes' && consent !== 'y') { - console.log('🚫 Invalid response. Cancelling elicitation.'); - return 'cancel'; - } - - // 3. Wait for completion notification in the background - const completionPromise = new Promise((resolve, reject) => { - const timeout = setTimeout( - () => { - pendingURLElicitations.delete(elicitationId); - console.log(`\u001B[31m❌ Elicitation ${elicitationId} timed out waiting for completion.\u001B[0m`); - reject(new Error('Elicitation completion timeout')); - }, - 5 * 60 * 1000 - ); // 5 minute timeout - - pendingURLElicitations.set(elicitationId, { - resolve: () => { - clearTimeout(timeout); - resolve(); - }, - reject, - timeout - }); - }); - - completionPromise.catch(error => { - console.error('Background completion wait failed:', error); - }); - - // 4. Open the URL in the browser - console.log(`\n🚀 Opening browser to: ${url}`); - await openBrowser(url); - - console.log('\n⏳ Waiting for you to complete the interaction in your browser...'); - console.log(' The server will send a notification once you complete the action.'); - - // 5. Acknowledge the user accepted the elicitation - return 'accept'; -} - -/** - * Example OAuth callback handler - in production, use a more robust approach - * for handling callbacks and storing tokens - */ -/** - * Starts a temporary HTTP server to receive the OAuth callback - */ -async function waitForOAuthCallback(): Promise { - return new Promise((resolve, reject) => { - const server = createServer((req, res) => { - // Ignore favicon requests - if (req.url === '/favicon.ico') { - res.writeHead(404); - res.end(); - return; - } - - console.log(`📥 Received callback: ${req.url}`); - const parsedUrl = new URL(req.url || '', 'http://localhost'); - const code = parsedUrl.searchParams.get('code'); - const error = parsedUrl.searchParams.get('error'); - - if (code) { - console.log(`✅ Authorization code received: ${code?.slice(0, 10)}...`); - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(` - - -

Authorization Successful!

-

This simulates successful authorization of the MCP client, which now has an access token for the MCP server.

-

This window will close automatically in 10 seconds.

- - - - `); - - resolve(code); - setTimeout(() => server.close(), 15_000); - } else if (error) { - console.log(`❌ Authorization error: ${error}`); - res.writeHead(400, { 'Content-Type': 'text/html' }); - res.end(` - - -

Authorization Failed

-

Error: ${error}

- - - `); - reject(new Error(`OAuth authorization failed: ${error}`)); - } else { - console.log(`❌ No authorization code or error in callback`); - res.writeHead(400); - res.end('Bad request'); - reject(new Error('No authorization code provided')); - } - }); - - server.listen(OAUTH_CALLBACK_PORT, () => { - console.log(`OAuth callback server started on http://localhost:${OAUTH_CALLBACK_PORT}`); - }); - }); -} - -/** - * Attempts to connect to the MCP server with OAuth authentication. - * Handles OAuth flow recursively if authorization is required. - */ -async function attemptConnection(oauthProvider: InMemoryOAuthClientProvider): Promise { - console.log('🚢 Creating transport with OAuth provider...'); - const baseUrl = new URL(serverUrl); - transport = new StreamableHTTPClientTransport(baseUrl, { - sessionId: sessionId, - authProvider: oauthProvider - }); - console.log('🚢 Transport created'); - - try { - console.log('🔌 Attempting connection (this will trigger OAuth redirect if needed)...'); - await client!.connect(transport); - sessionId = transport.sessionId; - console.log('Transport created with session ID:', sessionId); - console.log('✅ Connected successfully'); - } catch (error) { - if (error instanceof UnauthorizedError) { - console.log('🔐 OAuth required - waiting for authorization...'); - const callbackPromise = waitForOAuthCallback(); - const authCode = await callbackPromise; - await transport.finishAuth(authCode); - console.log('🔐 Authorization code received:', authCode); - console.log('🔌 Reconnecting with authenticated transport...'); - // Recursively retry connection after OAuth completion - await attemptConnection(oauthProvider); - } else { - console.error('❌ Connection failed with non-auth error:', error); - throw error; - } - } -} - -async function connect(url?: string): Promise { - if (client) { - console.log('Already connected. Disconnect first.'); - return; - } - - if (url) { - serverUrl = url; - } - - console.log(`🔗 Attempting to connect to ${serverUrl}...`); - - // Create a new client with elicitation capability - console.log('👤 Creating MCP client...'); - client = new Client( - { - name: 'example-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: { - // Only URL elicitation is supported in this demo - // (see server/elicitationExample.ts for a demo of form mode elicitation) - url: {} - } - } - } - ); - console.log('👤 Client created'); - - // Set up elicitation request handler with proper validation - client.setRequestHandler('elicitation/create', elicitationRequestHandler); - - // Set up notification handler for elicitation completion - client.setNotificationHandler('notifications/elicitation/complete', notification => { - const { elicitationId } = notification.params; - const pending = pendingURLElicitations.get(elicitationId); - if (pending) { - clearTimeout(pending.timeout); - pendingURLElicitations.delete(elicitationId); - console.log(`\u001B[32m✅ Elicitation ${elicitationId} completed!\u001B[0m`); - pending.resolve(); - } else { - // Shouldn't happen - discard it! - console.warn(`Received completion notification for unknown elicitation: ${elicitationId}`); - } - }); - - try { - console.log('🔐 Starting OAuth flow...'); - await attemptConnection(oauthProvider!); - console.log('Connected to MCP server'); - - // Set up error handler after connection is established so we don't double log errors - client.onerror = error => { - console.error('\u001B[31mClient error:', error, '\u001B[0m'); - }; - } catch (error) { - console.error('Failed to connect:', error); - client = null; - transport = null; - return; - } -} - -async function disconnect(): Promise { - if (!client || !transport) { - console.log('Not connected.'); - return; - } - - try { - await transport.close(); - console.log('Disconnected from MCP server'); - client = null; - transport = null; - } catch (error) { - console.error('Error disconnecting:', error); - } -} - -async function terminateSession(): Promise { - if (!client || !transport) { - console.log('Not connected.'); - return; - } - - try { - console.log('Terminating session with ID:', transport.sessionId); - await transport.terminateSession(); - console.log('Session terminated successfully'); - - // Check if sessionId was cleared after termination - if (transport.sessionId) { - console.log('Server responded with 405 Method Not Allowed (session termination not supported)'); - console.log('Session ID is still active:', transport.sessionId); - } else { - console.log('Session ID has been cleared'); - sessionId = undefined; - - // Also close the transport and clear client objects - await transport.close(); - console.log('Transport closed after session termination'); - client = null; - transport = null; - } - } catch (error) { - console.error('Error terminating session:', error); - } -} - -async function reconnect(): Promise { - if (client) { - await disconnect(); - } - await connect(); -} - -async function listTools(): Promise { - if (!client) { - console.log('Not connected to server.'); - return; - } - - try { - const toolsRequest: ListToolsRequest = { - method: 'tools/list', - params: {} - }; - const toolsResult = await client.request(toolsRequest); - - console.log('Available tools:'); - if (toolsResult.tools.length === 0) { - console.log(' No tools available'); - } else { - for (const tool of toolsResult.tools) { - console.log(` - id: ${tool.name}, name: ${getDisplayName(tool)}, description: ${tool.description}`); - } - } - } catch (error) { - console.log(`Tools not supported by this server (${error})`); - } -} - -async function callTool(name: string, args: Record): Promise { - if (!client) { - console.log('Not connected to server.'); - return; - } - - try { - console.log(`Calling tool '${name}' with args:`, args); - const result = await client.callTool({ name, arguments: args }); - - console.log('Tool result:'); - const resourceLinks: ResourceLink[] = []; - - for (const item of result.content) { - switch (item.type) { - case 'text': { - console.log(` ${item.text}`); - - break; - } - case 'resource_link': { - const resourceLink = item as ResourceLink; - resourceLinks.push(resourceLink); - console.log(` 📁 Resource Link: ${resourceLink.name}`); - console.log(` URI: ${resourceLink.uri}`); - if (resourceLink.mimeType) { - console.log(` Type: ${resourceLink.mimeType}`); - } - if (resourceLink.description) { - console.log(` Description: ${resourceLink.description}`); - } - - break; - } - case 'resource': { - console.log(` [Embedded Resource: ${item.resource.uri}]`); - - break; - } - case 'image': { - console.log(` [Image: ${item.mimeType}]`); - - break; - } - case 'audio': { - console.log(` [Audio: ${item.mimeType}]`); - - break; - } - default: { - console.log(` [Unknown content type]:`, item); - } - } - } - - // Offer to read resource links - if (resourceLinks.length > 0) { - console.log(`\nFound ${resourceLinks.length} resource link(s). Use 'read-resource ' to read their content.`); - } - } catch (error) { - if (error instanceof UrlElicitationRequiredError) { - console.log('\n🔔 Elicitation Required Error Received:'); - console.log(`Message: ${error.message}`); - for (const e of error.elicitations) { - await handleURLElicitation(e); // For the error handler, we discard the action result because we don't respond to an error response - } - return; - } - console.log(`Error calling tool ${name}: ${error}`); - } -} - -async function cleanup(): Promise { - if (client && transport) { - try { - // First try to terminate the session gracefully - if (transport.sessionId) { - try { - console.log('Terminating session before exit...'); - await transport.terminateSession(); - console.log('Session terminated successfully'); - } catch (error) { - console.error('Error terminating session:', error); - } - } - - // Then close the transport - await transport.close(); - } catch (error) { - console.error('Error closing transport:', error); - } - } - - process.stdin.setRawMode(false); - readline.close(); - console.log('\nGoodbye!'); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(0); -} - -async function callPaymentConfirmTool(): Promise { - console.log('Calling payment-confirm tool...'); - await callTool('payment-confirm', { cartId: 'cart_123' }); -} - -async function callThirdPartyAuthTool(): Promise { - console.log('Calling third-party-auth tool...'); - await callTool('third-party-auth', { param1: 'test' }); -} - -// Set up raw mode for keyboard input to capture Escape key -process.stdin.setRawMode(true); -process.stdin.on('data', async data => { - // Check for Escape key (27) - if (data.length === 1 && data[0] === 27) { - console.log('\nESC key pressed. Disconnecting from server...'); - - // Abort current operation and disconnect from server - if (client && transport) { - await disconnect(); - console.log('Disconnected. Press Enter to continue.'); - } else { - console.log('Not connected to server.'); - } - - // Re-display the prompt - process.stdout.write('> '); - } -}); - -// Handle Ctrl+C -process.on('SIGINT', async () => { - console.log('\nReceived SIGINT. Cleaning up...'); - await cleanup(); -}); - -// Start the interactive client -try { - await main(); -} catch (error) { - console.error('Error running MCP client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/client/src/multipleClientsParallel.ts b/examples/client/src/multipleClientsParallel.ts deleted file mode 100644 index 6543bae020..0000000000 --- a/examples/client/src/multipleClientsParallel.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { CallToolResult } from '@modelcontextprotocol/client'; -import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; - -/** - * Multiple Clients MCP Example - * - * This client demonstrates how to: - * 1. Create multiple MCP clients in parallel - * 2. Each client calls a single tool - * 3. Track notifications from each client independently - */ - -// Command line args processing -const args = process.argv.slice(2); -const serverUrl = args[0] || 'http://localhost:3000/mcp'; - -interface ClientConfig { - id: string; - name: string; - toolName: string; - toolArguments: Record; -} - -async function createAndRunClient(config: ClientConfig): Promise<{ id: string; result: CallToolResult }> { - console.log(`[${config.id}] Creating client: ${config.name}`); - - const client = new Client({ - name: config.name, - version: '1.0.0' - }); - - const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); - - // Set up client-specific error handler - client.onerror = error => { - console.error(`[${config.id}] Client error:`, error); - }; - - // Set up client-specific notification handler - client.setNotificationHandler('notifications/message', notification => { - console.log(`[${config.id}] Notification: ${notification.params.data}`); - }); - - try { - // Connect to the server - await client.connect(transport); - console.log(`[${config.id}] Connected to MCP server`); - - // Call the specified tool - console.log(`[${config.id}] Calling tool: ${config.toolName}`); - const result = await client.callTool({ - name: config.toolName, - arguments: { - ...config.toolArguments, - // Add client ID to arguments for identification in notifications - caller: config.id - } - }); - console.log(`[${config.id}] Tool call completed`); - - // Keep the connection open for a bit to receive notifications - await new Promise(resolve => setTimeout(resolve, 5000)); - - // Disconnect - await transport.close(); - console.log(`[${config.id}] Disconnected from MCP server`); - - return { id: config.id, result }; - } catch (error) { - console.error(`[${config.id}] Error:`, error); - throw error; - } -} - -async function main(): Promise { - console.log('MCP Multiple Clients Example'); - console.log('============================'); - console.log(`Server URL: ${serverUrl}`); - console.log(''); - - try { - // Define client configurations - const clientConfigs: ClientConfig[] = [ - { - id: 'client1', - name: 'basic-client-1', - toolName: 'start-notification-stream', - toolArguments: { - interval: 3, // 1 second between notifications - count: 5 // Send 5 notifications - } - }, - { - id: 'client2', - name: 'basic-client-2', - toolName: 'start-notification-stream', - toolArguments: { - interval: 2, // 2 seconds between notifications - count: 3 // Send 3 notifications - } - }, - { - id: 'client3', - name: 'basic-client-3', - toolName: 'start-notification-stream', - toolArguments: { - interval: 1, // 0.5 second between notifications - count: 8 // Send 8 notifications - } - } - ]; - - // Start all clients in parallel - console.log(`Starting ${clientConfigs.length} clients in parallel...`); - console.log(''); - - const clientPromises = clientConfigs.map(config => createAndRunClient(config)); - const results = await Promise.all(clientPromises); - - // Display results from all clients - console.log('\n=== Final Results ==='); - for (const { id, result } of results) { - console.log(`\n[${id}] Tool result:`); - if (Array.isArray(result.content)) { - for (const item of result.content) { - if (item.type === 'text' && item.text) { - console.log(` ${item.text}`); - } else { - console.log(` ${item.type} content:`, item); - } - } - } else { - console.log(` Unexpected result format:`, result); - } - } - - console.log('\n=== All clients completed successfully ==='); - } catch (error) { - console.error('Error running multiple clients:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } -} - -// Start the example -try { - await main(); -} catch (error) { - console.error('Error running multiple clients:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/client/src/parallelToolCallsClient.ts b/examples/client/src/parallelToolCallsClient.ts deleted file mode 100644 index 5b16cc9cc8..0000000000 --- a/examples/client/src/parallelToolCallsClient.ts +++ /dev/null @@ -1,175 +0,0 @@ -import type { CallToolResult, ListToolsRequest } from '@modelcontextprotocol/client'; -import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; - -/** - * Parallel Tool Calls MCP Client - * - * This client demonstrates how to: - * 1. Start multiple tool calls in parallel - * 2. Track notifications from each tool call using a caller parameter - */ - -// Command line args processing -const args = process.argv.slice(2); -const serverUrl = args[0] || 'http://localhost:3000/mcp'; - -async function main(): Promise { - console.log('MCP Parallel Tool Calls Client'); - console.log('=============================='); - console.log(`Connecting to server at: ${serverUrl}`); - - let client: Client; - let transport: StreamableHTTPClientTransport; - - try { - // Create client with streamable HTTP transport - client = new Client({ - name: 'parallel-tool-calls-client', - version: '1.0.0' - }); - - client.onerror = error => { - console.error('Client error:', error); - }; - - // Connect to the server - transport = new StreamableHTTPClientTransport(new URL(serverUrl)); - await client.connect(transport); - console.log('Successfully connected to MCP server'); - - // Set up notification handler with caller identification - client.setNotificationHandler('notifications/message', notification => { - console.log(`Notification: ${notification.params.data}`); - }); - - console.log('List tools'); - const toolsRequest = await listTools(client); - console.log('Tools:', toolsRequest); - - // 2. Start multiple notification tools in parallel - console.log('\n=== Starting Multiple Notification Streams in Parallel ==='); - const toolResults = await startParallelNotificationTools(client); - - // Log the results from each tool call - for (const [caller, result] of Object.entries(toolResults)) { - console.log(`\n=== Tool result for ${caller} ===`); - for (const item of result.content) { - if (item.type === 'text') { - console.log(` ${item.text}`); - } else { - console.log(` ${item.type} content:`, item); - } - } - } - - // 3. Wait for all notifications (10 seconds) - console.log('\n=== Waiting for all notifications ==='); - await new Promise(resolve => setTimeout(resolve, 10_000)); - - // 4. Disconnect - console.log('\n=== Disconnecting ==='); - await transport.close(); - console.log('Disconnected from MCP server'); - } catch (error) { - console.error('Error running client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } -} - -/** - * List available tools on the server - */ -async function listTools(client: Client): Promise { - try { - const toolsRequest: ListToolsRequest = { - method: 'tools/list', - params: {} - }; - const toolsResult = await client.request(toolsRequest); - - console.log('Available tools:'); - if (toolsResult.tools.length === 0) { - console.log(' No tools available'); - } else { - for (const tool of toolsResult.tools) { - console.log(` - ${tool.name}: ${tool.description}`); - } - } - } catch (error) { - console.log(`Tools not supported by this server: ${error}`); - } -} - -/** - * Start multiple notification tools in parallel with different configurations - * Each tool call includes a caller parameter to identify its notifications - */ -async function startParallelNotificationTools(client: Client): Promise> { - try { - // Define multiple tool calls with different configurations - const toolCalls = [ - { - caller: 'fast-notifier', - args: { - interval: 2, // 0.5 second between notifications - count: 10, // Send 10 notifications - caller: 'fast-notifier' // Identify this tool call - } - }, - { - caller: 'slow-notifier', - args: { - interval: 5, // 2 seconds between notifications - count: 5, // Send 5 notifications - caller: 'slow-notifier' // Identify this tool call - } - }, - { - caller: 'burst-notifier', - args: { - interval: 1, // 0.1 second between notifications - count: 3, // Send just 3 notifications - caller: 'burst-notifier' // Identify this tool call - } - } - ]; - - console.log(`Starting ${toolCalls.length} notification tools in parallel...`); - - // Start all tool calls in parallel - const toolPromises = toolCalls.map(({ caller, args }) => { - console.log(`Starting tool call for ${caller}...`); - return client - .callTool({ name: 'start-notification-stream', arguments: args }) - .then(result => ({ caller, result })) - .catch(error => { - console.error(`Error in tool call for ${caller}:`, error); - throw error; - }); - }); - - // Wait for all tool calls to complete - const results = await Promise.all(toolPromises); - - // Organize results by caller - const resultsByTool: Record = {}; - for (const { caller, result } of results) { - resultsByTool[caller] = result; - } - - return resultsByTool; - } catch (error) { - console.error(`Error starting parallel notification tools:`, error); - throw error; - } -} - -try { - // Run the client - await main(); -} catch (error) { - console.error('Error running client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/client/src/simpleClientCredentials.ts b/examples/client/src/simpleClientCredentials.ts deleted file mode 100644 index 58f17e312a..0000000000 --- a/examples/client/src/simpleClientCredentials.ts +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env node - -/** - * Example demonstrating client_credentials grant for machine-to-machine authentication. - * - * Supports two authentication methods based on environment variables: - * - * 1. client_secret_basic (default): - * MCP_CLIENT_ID - OAuth client ID (required) - * MCP_CLIENT_SECRET - OAuth client secret (required) - * - * 2. private_key_jwt (when MCP_CLIENT_PRIVATE_KEY_PEM is set): - * MCP_CLIENT_ID - OAuth client ID (required) - * MCP_CLIENT_PRIVATE_KEY_PEM - PEM-encoded private key for JWT signing (required) - * MCP_CLIENT_ALGORITHM - Signing algorithm (default: RS256) - * - * Common: - * MCP_SERVER_URL - Server URL (default: http://localhost:3000/mcp) - */ - -import type { OAuthClientProvider } from '@modelcontextprotocol/client'; -import { Client, ClientCredentialsProvider, PrivateKeyJwtProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; - -const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp'; - -function createProvider(): OAuthClientProvider { - const clientId = process.env.MCP_CLIENT_ID; - if (!clientId) { - console.error('MCP_CLIENT_ID environment variable is required'); - process.exit(1); - } - - // If private key is provided, use private_key_jwt authentication - const privateKeyPem = process.env.MCP_CLIENT_PRIVATE_KEY_PEM; - if (privateKeyPem) { - const algorithm = process.env.MCP_CLIENT_ALGORITHM || 'RS256'; - console.log('Using private_key_jwt authentication'); - return new PrivateKeyJwtProvider({ - clientId, - privateKey: privateKeyPem, - algorithm - }); - } - - // Otherwise, use client_secret_basic authentication - const clientSecret = process.env.MCP_CLIENT_SECRET; - if (!clientSecret) { - console.error('MCP_CLIENT_SECRET or MCP_CLIENT_PRIVATE_KEY_PEM environment variable is required'); - process.exit(1); - } - - console.log('Using client_secret_basic authentication'); - return new ClientCredentialsProvider({ - clientId, - clientSecret - }); -} - -async function main() { - const provider = createProvider(); - - const client = new Client({ name: 'client-credentials-example', version: '1.0.0' }, { capabilities: {} }); - - const transport = new StreamableHTTPClientTransport(new URL(DEFAULT_SERVER_URL), { - authProvider: provider - }); - - await client.connect(transport); - console.log('Connected successfully.'); - - const tools = await client.listTools(); - console.log('Available tools:', tools.tools.map(t => t.name).join(', ') || '(none)'); - - await transport.close(); -} - -try { - await main(); -} catch (error) { - console.error('Error running client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/client/src/ssePollingClient.ts b/examples/client/src/ssePollingClient.ts deleted file mode 100644 index 2d1115e72a..0000000000 --- a/examples/client/src/ssePollingClient.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * SSE Polling Example Client (SEP-1699) - * - * This example demonstrates client-side behavior during server-initiated - * SSE stream disconnection and automatic reconnection. - * - * Key features demonstrated: - * - Automatic reconnection when server closes SSE stream - * - Event replay via Last-Event-ID header - * - Resumption token tracking via onresumptiontoken callback - * - * Run with: pnpm tsx src/ssePollingClient.ts - * Requires: ssePollingExample.ts server running on port 3001 - */ -import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; - -const SERVER_URL = 'http://localhost:3001/mcp'; - -async function main(): Promise { - console.log('SSE Polling Example Client'); - console.log('=========================='); - console.log(`Connecting to ${SERVER_URL}...`); - console.log(''); - - // Create transport with reconnection options - const transport = new StreamableHTTPClientTransport(new URL(SERVER_URL), { - // Use default reconnection options - SDK handles automatic reconnection - }); - - // Track the last event ID for debugging - let lastEventId: string | undefined; - - // Set up transport error handler to observe disconnections - // Filter out expected errors from SSE reconnection - transport.onerror = error => { - // Skip abort errors during intentional close - if (error.message.includes('AbortError')) return; - // Show SSE disconnect (expected when server closes stream) - if (error.message.includes('Unexpected end of JSON')) { - console.log('[Transport] SSE stream disconnected - client will auto-reconnect'); - return; - } - console.log(`[Transport] Error: ${error.message}`); - }; - - // Set up transport close handler - transport.onclose = () => { - console.log('[Transport] Connection closed'); - }; - - // Create and connect client - const client = new Client({ - name: 'sse-polling-client', - version: '1.0.0' - }); - - // Set up notification handler to receive progress updates - client.setNotificationHandler('notifications/message', notification => { - const data = notification.params.data; - console.log(`[Notification] ${data}`); - }); - - try { - await client.connect(transport); - console.log('[Client] Connected successfully'); - console.log(''); - - // Call the long-operation tool - console.log('[Client] Calling long-operation tool...'); - console.log('[Client] Server will disconnect mid-operation to demonstrate polling'); - console.log(''); - - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'long-operation', - arguments: {} - } - }, - { - // Track resumption tokens for debugging - onresumptiontoken: token => { - lastEventId = token; - console.log(`[Event ID] ${token}`); - } - } - ); - - console.log(''); - console.log('[Client] Tool completed!'); - console.log(`[Result] ${JSON.stringify(result.content, null, 2)}`); - console.log(''); - console.log(`[Debug] Final event ID: ${lastEventId}`); - } catch (error) { - console.error('[Error]', error); - } finally { - await transport.close(); - console.log('[Client] Disconnected'); - } -} - -try { - await main(); -} catch (error) { - console.error('Error running MCP client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/client/src/streamableHttpWithSseFallbackClient.ts b/examples/client/src/streamableHttpWithSseFallbackClient.ts deleted file mode 100644 index 0925f8dd0b..0000000000 --- a/examples/client/src/streamableHttpWithSseFallbackClient.ts +++ /dev/null @@ -1,181 +0,0 @@ -import type { ListToolsRequest } from '@modelcontextprotocol/client'; -import { Client, SSEClientTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; - -/** - * Simplified Backwards Compatible MCP Client - * - * This client demonstrates backward compatibility with both: - * 1. Modern servers using Streamable HTTP transport (protocol version 2025-03-26) - * 2. Older servers using HTTP+SSE transport (protocol version 2024-11-05) - * - * Following the MCP specification for backwards compatibility: - * - Attempts to POST an initialize request to the server URL first (modern transport) - * - If that fails with 4xx status, falls back to GET request for SSE stream (older transport) - */ - -// Command line args processing -const args = process.argv.slice(2); -const serverUrl = args[0] || 'http://localhost:3000/mcp'; - -async function main(): Promise { - console.log('MCP Backwards Compatible Client'); - console.log('==============================='); - console.log(`Connecting to server at: ${serverUrl}`); - - let client: Client; - let transport: StreamableHTTPClientTransport | SSEClientTransport; - - try { - // Try connecting with automatic transport detection - const connection = await connectWithBackwardsCompatibility(serverUrl); - client = connection.client; - transport = connection.transport; - - // Set up notification handler - client.setNotificationHandler('notifications/message', notification => { - console.log(`Notification: ${notification.params.level} - ${notification.params.data}`); - }); - - // DEMO WORKFLOW: - // 1. List available tools - console.log('\n=== Listing Available Tools ==='); - await listTools(client); - - // 2. Call the notification tool - console.log('\n=== Starting Notification Stream ==='); - await startNotificationTool(client); - - // 3. Wait for all notifications (5 seconds) - console.log('\n=== Waiting for all notifications ==='); - await new Promise(resolve => setTimeout(resolve, 5000)); - - // 4. Disconnect - console.log('\n=== Disconnecting ==='); - await transport.close(); - console.log('Disconnected from MCP server'); - } catch (error) { - console.error('Error running client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } -} - -/** - * Connect to an MCP server with backwards compatibility - * Following the spec for client backward compatibility - */ -async function connectWithBackwardsCompatibility(url: string): Promise<{ - client: Client; - transport: StreamableHTTPClientTransport | SSEClientTransport; - transportType: 'streamable-http' | 'sse'; -}> { - console.log('1. Trying Streamable HTTP transport first...'); - - // Step 1: Try Streamable HTTP transport first - const client = new Client({ - name: 'backwards-compatible-client', - version: '1.0.0' - }); - - client.onerror = error => { - console.error('Client error:', error); - }; - const baseUrl = new URL(url); - - try { - // Create modern transport - const streamableTransport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(streamableTransport); - - console.log('Successfully connected using modern Streamable HTTP transport.'); - return { - client, - transport: streamableTransport, - transportType: 'streamable-http' - }; - } catch (error) { - // Step 2: If transport fails, try the older SSE transport - console.log(`StreamableHttp transport connection failed: ${error}`); - console.log('2. Falling back to deprecated HTTP+SSE transport...'); - - try { - // Create SSE transport pointing to /sse endpoint - const sseTransport = new SSEClientTransport(baseUrl); - const sseClient = new Client({ - name: 'backwards-compatible-client', - version: '1.0.0' - }); - await sseClient.connect(sseTransport); - - console.log('Successfully connected using deprecated HTTP+SSE transport.'); - return { - client: sseClient, - transport: sseTransport, - transportType: 'sse' - }; - } catch (sseError) { - console.error(`Failed to connect with either transport method:\n1. Streamable HTTP error: ${error}\n2. SSE error: ${sseError}`); - throw new Error('Could not connect to server with any available transport'); - } - } -} - -/** - * List available tools on the server - */ -async function listTools(client: Client): Promise { - try { - const toolsRequest: ListToolsRequest = { - method: 'tools/list', - params: {} - }; - const toolsResult = await client.request(toolsRequest); - - console.log('Available tools:'); - if (toolsResult.tools.length === 0) { - console.log(' No tools available'); - } else { - for (const tool of toolsResult.tools) { - console.log(` - ${tool.name}: ${tool.description}`); - } - } - } catch (error) { - console.log(`Tools not supported by this server: ${error}`); - } -} - -/** - * Start a notification stream by calling the notification tool - */ -async function startNotificationTool(client: Client): Promise { - try { - console.log('Calling notification tool...'); - const result = await client.callTool({ - name: 'start-notification-stream', - arguments: { - interval: 1000, // 1 second between notifications - count: 5 // Send 5 notifications - } - }); - - console.log('Tool result:'); - for (const item of result.content) { - if (item.type === 'text') { - console.log(` ${item.text}`); - } else { - console.log(` ${item.type} content:`, item); - } - } - } catch (error) { - console.log(`Error calling notification tool: ${error}`); - } -} - -// Start the client -try { - await main(); -} catch (error) { - console.error('Error running MCP client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/client/tsconfig.json b/examples/client/tsconfig.json deleted file mode 100644 index 5c1f7fc764..0000000000 --- a/examples/client/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "extends": "@modelcontextprotocol/tsconfig", - "include": ["./"], - "exclude": ["node_modules", "dist"], - "compilerOptions": { - "paths": { - "*": ["./*"], - "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], - "@modelcontextprotocol/client/stdio": ["./node_modules/@modelcontextprotocol/client/src/stdio.ts"], - "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], - "@modelcontextprotocol/core": [ - "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core/src/index.ts" - ], - "@modelcontextprotocol/core/public": [ - "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core/src/exports/public/index.ts" - ], - "@modelcontextprotocol/eslint-config": ["./node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"], - "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], - "@modelcontextprotocol/examples-shared": ["./node_modules/@modelcontextprotocol/examples-shared/src/index.ts"] - } - } -} diff --git a/examples/client/tsdown.config.ts b/examples/client/tsdown.config.ts deleted file mode 100644 index efc4299d35..0000000000 --- a/examples/client/tsdown.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { defineConfig } from 'tsdown'; - -export default defineConfig({ - // 1. Entry Points - // Directly matches package.json include/exclude globs - entry: ['src/**/*.ts'], - - // 2. Output Configuration - format: ['esm'], - outDir: 'dist', - clean: true, // Recommended: Cleans 'dist' before building - sourcemap: true, - - // 3. Platform & Target - target: 'esnext', - platform: 'node', - shims: true, // Polyfills common Node.js shims (__dirname, etc.) - - // 4. Type Definitions - // Bundles d.ts files into a single output - dts: false, - // 5. Vendoring Strategy - Bundle the code for this specific package into the output, - // but treat all other dependencies as external (require/import). - noExternal: ['@modelcontextprotocol/examples-shared'] -}); diff --git a/examples/client/vitest.config.js b/examples/client/vitest.config.js deleted file mode 100644 index 496fca3200..0000000000 --- a/examples/client/vitest.config.js +++ /dev/null @@ -1,3 +0,0 @@ -import baseConfig from '@modelcontextprotocol/vitest-config'; - -export default baseConfig; diff --git a/examples/custom-methods/README.md b/examples/custom-methods/README.md new file mode 100644 index 0000000000..6f778ebee9 --- /dev/null +++ b/examples/custom-methods/README.md @@ -0,0 +1,8 @@ +# custom-methods + +Bidirectional custom (non-spec) JSON-RPC methods: the server handles a vendor-prefixed `acme/search` request via `server.setRequestHandler` and emits `acme/searchProgress` notifications via `ctx.mcpReq.notify`; the client sends the typed request via +`client.request(method, schema)` and receives the typed notifications via `client.setNotificationHandler('acme/searchProgress', { params })`. + +```bash +pnpm tsx examples/custom-methods/client.ts +``` diff --git a/examples/custom-methods/client.ts b/examples/custom-methods/client.ts new file mode 100644 index 0000000000..e4ca552013 --- /dev/null +++ b/examples/custom-methods/client.ts @@ -0,0 +1,42 @@ +/** + * Custom (non-spec) method example: a client that sends `acme/search` and + * listens for `acme/searchProgress` notifications. + * + * Spawns the sibling `server.ts` over stdio by default, or connects to a + * running endpoint under `--http `. See `examples/CONTRIBUTING.md` for + * the canonical shape. + */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { z } from 'zod/v4'; + +const SearchResult = z.object({ items: z.array(z.string()) }); +const SearchProgressParams = z.object({ stage: z.string(), pct: z.number() }); + +const { transport, url, era } = parseExampleArgs(); + +// Vendor-prefixed methods route through both serving entries unchanged: a +// 2025 client sends the bare JSON-RPC request, a 2026-07-28 client sends it +// with the per-request envelope; `setRequestHandler` receives either. +const client = new Client( + { name: 'custom-methods-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); + +await client.connect( + transport === 'stdio' + ? new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] }) + : new StreamableHTTPClientTransport(new URL(url)) +); + +const stages: string[] = []; +client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => { + stages.push(params.stage); +}); + +const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult); +check.deepEqual(result.items, ['mcp-0', 'mcp-1', 'mcp-2']); +check.deepEqual(stages, ['start', 'done']); + +await client.close(); diff --git a/examples/custom-methods/package.json b/examples/custom-methods/package.json new file mode 100644 index 0000000000..3d5761985b --- /dev/null +++ b/examples/custom-methods/package.json @@ -0,0 +1,23 @@ +{ + "name": "@mcp-examples/custom-methods", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "dual", + "//": "Vendor-prefixed methods route through both serving entries unchanged: a 2025 client sends the bare JSON-RPC request, a 2026-07-28 client sends it with the per-request envelope; setRequestHandler receives either." + } +} diff --git a/examples/custom-methods/server.ts b/examples/custom-methods/server.ts new file mode 100644 index 0000000000..b21e8e4963 --- /dev/null +++ b/examples/custom-methods/server.ts @@ -0,0 +1,42 @@ +/** + * Custom (non-spec) method example: a server that handles a vendor-prefixed + * `acme/search` request and emits `acme/searchProgress` notifications. + * + * One binary, either transport — selected by `--http --port ` (defaults to + * stdio). See `examples/CONTRIBUTING.md` for the canonical shape. + */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import { z } from 'zod/v4'; + +const SearchParams = z.object({ query: z.string(), limit: z.number().int().default(10) }); +const SearchResult = z.object({ items: z.array(z.string()) }); + +function buildServer(): McpServer { + const mcp = new McpServer({ name: 'acme-search', version: '0.0.0' }); + + mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => { + await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } }); + const items = Array.from({ length: params.limit }, (_, i) => `${params.query}-${i}`); + await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'done', pct: 1 } }); + return { items }; + }); + + return mcp; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/custom-version/README.md b/examples/custom-version/README.md new file mode 100644 index 0000000000..1bd05975ad --- /dev/null +++ b/examples/custom-version/README.md @@ -0,0 +1,7 @@ +# custom-version + +`ServerOptions.supportedProtocolVersions` — declare support for protocol versions not yet in the SDK. The first version in the list is the fallback when a client requests an unsupported one. + +```bash +pnpm tsx examples/custom-version/client.ts +``` diff --git a/examples/custom-version/client.ts b/examples/custom-version/client.ts new file mode 100644 index 0000000000..e25ccb53a9 --- /dev/null +++ b/examples/custom-version/client.ts @@ -0,0 +1,27 @@ +/** + * Initializes with a protocol version the server lists in + * `supportedProtocolVersions` (and one it does not, to assert the fallback). + */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const { transport, url } = parseExampleArgs(); + +// A plain (2025-handshake) client; the server supports the SDK's stock +// 2025 version so this negotiates that. +const client = new Client({ name: 'custom-version-example-client', version: '1.0.0' }, { versionNegotiation: { mode: 'legacy' } }); + +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + +// The server should advertise its supportedProtocolVersions in its +// tool's text payload. +const result = await client.callTool({ name: 'get-protocol-info' }); +const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '{}'; +const info = JSON.parse(text) as { supportedVersions: string[] }; +check.ok(info.supportedVersions.includes('2026-01-01')); +check.ok(info.supportedVersions.length > 1); + +await client.close(); diff --git a/examples/custom-version/package.json b/examples/custom-version/package.json new file mode 100644 index 0000000000..d4f51326d9 --- /dev/null +++ b/examples/custom-version/package.json @@ -0,0 +1,22 @@ +{ + "name": "@mcp-examples/custom-version", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "legacy", + "//": "supportedProtocolVersions / version negotiation is the 2025 initialize handshake; the modern era is its own negotiation story (../dual-era/)." + } +} diff --git a/examples/custom-version/server.ts b/examples/custom-version/server.ts new file mode 100644 index 0000000000..ae31799dea --- /dev/null +++ b/examples/custom-version/server.ts @@ -0,0 +1,39 @@ +/** + * `supportedProtocolVersions`: support a protocol version not yet in the SDK. + * The first version in the list is the fallback when the client requests an + * unsupported one. One binary, either transport. + */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; + +// Add support for a newer protocol version (first in list is fallback). +const CUSTOM_VERSIONS = ['2026-01-01', ...SUPPORTED_PROTOCOL_VERSIONS]; + +function buildServer(): McpServer { + const server = new McpServer( + { name: 'custom-protocol-server', version: '1.0.0' }, + { supportedProtocolVersions: CUSTOM_VERSIONS, capabilities: { tools: {} } } + ); + + server.registerTool('get-protocol-info', { description: 'Returns protocol version configuration' }, async () => ({ + content: [{ type: 'text', text: JSON.stringify({ supportedVersions: CUSTOM_VERSIONS }) }] + })); + + return server; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/dual-era/README.md b/examples/dual-era/README.md new file mode 100644 index 0000000000..0da44000b2 --- /dev/null +++ b/examples/dual-era/README.md @@ -0,0 +1,12 @@ +# dual-era + +One server factory, both protocol eras (2025 `initialize` and 2026-07-28 per-request envelope), both transports (stdio and Streamable HTTP). The client connects once as a plain 2025 client and once with `versionNegotiation: { mode: 'auto' }`; the same `greet` tool answers both +and reports which era served the call. + +This is the recommended **first** example to read if you are migrating an existing server to the 2026 era: the entry (`serveStdio` / `createMcpHandler`) owns the era decision, the factory is era-agnostic. + +```bash +pnpm tsx examples/dual-era/client.ts # stdio +pnpm tsx examples/dual-era/server.ts --http --port 3000 # term 1 +pnpm tsx examples/dual-era/client.ts --http http://127.0.0.1:3000/ # term 2 +``` diff --git a/examples/dual-era/client.ts b/examples/dual-era/client.ts new file mode 100644 index 0000000000..c3a41c5094 --- /dev/null +++ b/examples/dual-era/client.ts @@ -0,0 +1,49 @@ +/** + * Drives the dual-era server (`./server.ts`) over the selected transport with + * BOTH kinds of client: + * + * 1. a plain 2025 client (`versionNegotiation: { mode: 'legacy' }`) — the + * `initialize` handshake, served exactly as today (the server reports + * `era === 'legacy'`); + * 2. a 2026-capable client (`versionNegotiation: { mode: 'auto' }`) — the + * `server/discover` probe negotiates the 2026-07-28 revision (no + * `initialize` is ever sent) and the SDK attaches the per-request `_meta` + * envelope itself (the server reports `era === 'modern'`). + * + * Asserts both legs and exits 0 — used as a self-verifying e2e by + * `scripts/examples/run-examples.ts` over stdio AND http. + */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +// The story body drives BOTH eras itself, so the argv `era` flag is unused; +// only the transport leg is read from argv. +const { transport, url } = parseExampleArgs(); + +const connect = async (mode: 'legacy' | 'auto'): Promise => { + const client = new Client({ name: 'dual-era-example-client', version: '1.0.0' }, { versionNegotiation: { mode } }); + await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + return client; +}; + +// --- leg 1: plain 2025 client (initialize handshake) --- +const legacy = await connect('legacy'); +const legacyTools = await legacy.listTools(); +check.ok(legacyTools.tools.some(t => t.name === 'greet')); +const legacyGreet = await legacy.callTool({ name: 'greet', arguments: { name: '2025 client' } }); +const legacyText = legacyGreet.content?.[0]?.type === 'text' ? legacyGreet.content[0].text : ''; +check.match(legacyText, /Hello, 2025 client! \(served on the legacy protocol era\)/); +await legacy.close(); + +// --- leg 2: 2026-capable client (server/discover negotiation) --- +const modern = await connect('auto'); +check.equal(modern.getNegotiatedProtocolVersion(), '2026-07-28'); +const modernGreet = await modern.callTool({ name: 'greet', arguments: { name: '2026 client' } }); +const modernText = modernGreet.content?.[0]?.type === 'text' ? modernGreet.content[0].text : ''; +check.match(modernText, /Hello, 2026 client! \(served on the modern protocol era\)/); +await modern.close(); + +console.log('both eras served by the same factory over the same transport.'); diff --git a/examples/dual-era/package.json b/examples/dual-era/package.json new file mode 100644 index 0000000000..a9b0fe8737 --- /dev/null +++ b/examples/dual-era/package.json @@ -0,0 +1,23 @@ +{ + "name": "@mcp-examples/dual-era", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "modern", + "//": "The story body drives BOTH eras itself (legacy via { mode: 'legacy' }, modern via { mode: 'auto' }); pinned so the runner runs it once per transport." + } +} diff --git a/examples/dual-era/server.ts b/examples/dual-era/server.ts new file mode 100644 index 0000000000..9313acb436 --- /dev/null +++ b/examples/dual-era/server.ts @@ -0,0 +1,55 @@ +/** + * Dual-era serving from one factory, both transports. + * + * The same factory backs both protocol eras: a 2025-era client connects with + * the `initialize` handshake; a 2026-capable client + * (`versionNegotiation: { mode: 'auto' }`) probes with `server/discover`, + * negotiates the 2026-07-28 revision, and the SDK attaches the per-request + * `_meta` envelope to every outgoing request itself. Tools are defined once + * and served identically to either kind of client. + * + * One binary, either transport (selected from argv): stdio by default + * (`serveStdio(buildServer)`), or HTTP under `--http --port ` + * (`createMcpHandler(buildServer)` on its default posture — modern served per + * request, 2025-era traffic served stateless from the same factory). + */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import type { CallToolResult, McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +const buildServer = (ctx: McpRequestContext): McpServer => { + 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 { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/elicitation/README.md b/examples/elicitation/README.md new file mode 100644 index 0000000000..de3443bd68 --- /dev/null +++ b/examples/elicitation/README.md @@ -0,0 +1,19 @@ +# elicitation + +Server requests user input. One factory, both protocol eras: elicitation works on both eras with different APIs — push-style on 2025, `inputRequired` on 2026; the protocol carries it differently but the user experience is the same. + +| Mode | 2025-era (`--legacy`, push-style) | 2026-07-28 (multi-round-trip) | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **form** (`register_user`) | `await ctx.mcpReq.elicitInput({ mode: 'form', requestedSchema })` — the server pushes an `elicitation/create` request and awaits the answer in-line | `return inputRequired({ inputRequests: { form: inputRequired.elicit(...) } })` — the client collects the form and retries the same handler with the response attached | +| **url** (`link_account`) | `await ctx.mcpReq.elicitInput({ mode: 'url', url, elicitationId })` + `createElicitationCompletionNotifier(elicitationId)` for the out-of-band `notifications/elicitation/complete` | `return inputRequired({ inputRequests: { auth: inputRequired.elicitUrl(...) } })` — no `elicitationId` / complete notification on this era | +| **url, throw** (`confirm_payment`) | `throw new UrlElicitationRequiredError([...])` — the wire `-32042`; the client catches the typed error and reads `.elicitations` | n/a — a throw on this era fails loudly with a steer to `inputRequired.elicitUrl(...)` | + +`plan_trip` chains **two** form elicitations inside one tool call (destination → dates for that destination): two sequential `ctx.mcpReq.elicitInput` pushes on 2025, two `inputRequired` rounds with `requestState` carry-over on 2026. The `register_user` form schema includes an +`enumNames` field (display labels for the `plan` enum). For the secure `requestState` round-trip pattern see [`../mrtr/`](../mrtr/README.md). + +Runs all four transport/era legs: `server.ts` inlines a sessionful `NodeStreamableHTTPServerTransport` arm for 2025 traffic (the same `isLegacyRequest` composition `../legacy-routing/` shows by hand), so push server→client requests reach the client over either transport. + +```bash +pnpm --filter @mcp-examples/elicitation client # 2026-07-28 (inputRequired) +pnpm --filter @mcp-examples/elicitation client -- --legacy # 2025 (push-style) +``` diff --git a/examples/elicitation/client.ts b/examples/elicitation/client.ts new file mode 100644 index 0000000000..66236d446d --- /dev/null +++ b/examples/elicitation/client.ts @@ -0,0 +1,98 @@ +/** + * Auto-answers form and URL elicitations on either protocol era and asserts + * the tool's text reflects the elicitation outcome. + * + * On the 2025-era leg (`--legacy`) the server pushes `elicitation/create` + * requests and a `notifications/elicitation/complete` notification, and the + * `confirm_payment` tool throws a typed `UrlElicitationRequiredError` the + * client catches. On the 2026-07-28 leg the same `elicitation/create` + * handler is dispatched by the auto-fulfilment engine for the embedded + * `inputRequired` requests; there is no throw-style or complete-notification + * surface on that era, so those assertions are gated to the legacy leg. + */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import type { ElicitRequestURLParams, ElicitResult } from '@modelcontextprotocol/client'; +import { Client, StreamableHTTPClientTransport, UrlElicitationRequiredError } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const { transport, url, era } = parseExampleArgs(); + +const client = new Client( + { name: 'elicitation-example-client', version: '1.0.0' }, + { + versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' }, + capabilities: { elicitation: { form: {}, url: {} } } + } +); + +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + +// URL-mode requests on the 2025 era carry an `elicitationId`; the client +// waits for `notifications/elicitation/complete` with that id (the +// out-of-band "the user finished the URL flow" signal) before answering. +const completed = new Map void>(); +client.setNotificationHandler('notifications/elicitation/complete', notification => { + const id = (notification.params as { elicitationId: string }).elicitationId; + completed.get(id)?.(); +}); + +let formAction: 'accept' | 'decline' = 'accept'; +client.setRequestHandler('elicitation/create', async (request): Promise => { + const params = request.params as { mode?: 'form' | 'url'; requestedSchema?: { properties?: Record } } & Partial< + Pick + >; + if (params.mode === 'url') { + // A real client would open `params.url` in a browser here. On the + // 2025 era it then waits for the matching complete notification + // before resolving; on the 2026 era there is no elicitationId and + // the client answers as soon as the user finishes. + check.ok(params.url?.startsWith('https://example.com/')); + if (params.elicitationId) { + await new Promise(resolve => completed.set(params.elicitationId as string, resolve)); + } + return { action: 'accept' }; + } + if (params.requestedSchema?.properties?.['destination']) { + return { action: 'accept', content: { destination: 'Tokyo' } }; + } + if (params.requestedSchema?.properties?.['departure']) { + return { action: 'accept', content: { departure: '2026-09-01', nights: 7 } }; + } + check.ok(params.requestedSchema?.properties?.['username'], 'elicitation should carry the requestedSchema'); + if (formAction === 'decline') return { action: 'decline' }; + return { action: 'accept', content: { username: 'alice', email: 'alice@example.com', plan: 'pro' } }; +}); + +// ---- Form mode (accept then decline) ------------------------------------- +const accepted = await client.callTool({ name: 'register_user' }); +check.match(accepted.content?.[0]?.type === 'text' ? accepted.content[0].text : '', /registered alice \(plan: pro\)/); + +formAction = 'decline'; +const declined = await client.callTool({ name: 'register_user' }); +check.match(declined.content?.[0]?.type === 'text' ? declined.content[0].text : '', /registration decline/); + +// ---- Multi-step form (two chained elicitations inside one tool call) ----- +const trip = await client.callTool({ name: 'plan_trip' }); +check.match(trip.content?.[0]?.type === 'text' ? trip.content[0].text : '', /trip planned: Tokyo on 2026-09-01 for 7 nights/); + +// ---- URL mode (push-style on 2025, inputRequired.elicitUrl on 2026) ------ +const linked = await client.callTool({ name: 'link_account', arguments: { provider: 'github' } }); +check.match(linked.content?.[0]?.type === 'text' ? linked.content[0].text : '', /linked github/); + +// ---- URL mode (throw-style — 2025-era only) ------------------------------ +if (era === 'legacy') { + let caught: UrlElicitationRequiredError | undefined; + try { + await client.callTool({ name: 'confirm_payment', arguments: { cartId: 'cart-42' } }); + } catch (error) { + check.ok(error instanceof UrlElicitationRequiredError, 'expected UrlElicitationRequiredError'); + caught = error as UrlElicitationRequiredError; + } + check.ok(caught, 'confirm_payment should throw UrlElicitationRequiredError on the 2025 era'); + check.equal(caught?.elicitations.length, 1); + check.match(caught?.elicitations[0]?.url ?? '', /confirm-payment\?cart=cart-42/); +} + +await client.close(); diff --git a/examples/elicitation/package.json b/examples/elicitation/package.json new file mode 100644 index 0000000000..05635c8ddc --- /dev/null +++ b/examples/elicitation/package.json @@ -0,0 +1,23 @@ +{ + "name": "@mcp-examples/elicitation", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "dual", + "//": "2025-era push-style runs over the sessionful legacy arm inlined in server.ts; 2026-07-28 inputRequired runs over the per-request modern arm. All four transport/era legs." + } +} diff --git a/examples/elicitation/server.ts b/examples/elicitation/server.ts new file mode 100644 index 0000000000..f0f1d7faa1 --- /dev/null +++ b/examples/elicitation/server.ts @@ -0,0 +1,302 @@ +/** + * Elicitation — server requests user input. One factory, both protocol eras. + * + * The same tools serve both eras with different APIs: on a 2025-era + * connection (`--legacy`, the `initialize` handshake) the server uses the + * push-style server→client request flow — `ctx.mcpReq.elicitInput(...)` for + * form and URL mode, `UrlElicitationRequiredError` for the throw-style URL + * signal, and `createElicitationCompletionNotifier` for the out-of-band + * `notifications/elicitation/complete`. On a 2026-07-28 connection there is + * no server→client request channel: the same tools instead **return** + * `inputRequired(...)` (multi-round-trip) and the client retries with the + * collected responses. The protocol carries the request differently; the user + * experience is the same. + * + * One binary, either transport (selected from argv). On HTTP the 2025-era arm + * is **sessionful** (`NodeStreamableHTTPServerTransport`): push-style + * `elicitation/create` needs the `initialize`-declared client capabilities and + * the bidirectional SSE stream of a session, neither of which the per-request + * stateless legacy fallback can provide. + */ +import { randomUUID } from 'node:crypto'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { NodeStreamableHTTPServerTransport, toNodeHandler } from '@modelcontextprotocol/node'; +import type { + CallToolResult, + ElicitRequestFormParams, + ElicitRequestURLParams, + InputRequiredResult, + McpRequestContext +} from '@modelcontextprotocol/server'; +import { + acceptedContent, + createMcpHandler, + inputRequired, + isInitializeRequest, + isLegacyRequest, + McpServer, + UrlElicitationRequiredError +} from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +// The form schema (with `enumNames` display labels for the enum field). +const REGISTRATION_SCHEMA: ElicitRequestFormParams['requestedSchema'] = { + type: 'object', + properties: { + username: { type: 'string', title: 'Username', minLength: 3, maxLength: 20 }, + email: { type: 'string', title: 'Email', format: 'email' }, + plan: { + type: 'string', + title: 'Plan', + enum: ['free', 'pro', 'team'], + enumNames: ['Free tier', 'Pro', 'Team'] + } + }, + required: ['username', 'email'] +}; + +type Registration = { username: string; email: string; plan?: string }; + +function buildServer(reqCtx: McpRequestContext): McpServer { + const server = new McpServer({ name: 'elicitation-example', version: '1.0.0' }); + + // ---- Form-mode elicitation ----------------------------------------------- + server.registerTool( + 'register_user', + { description: 'Register a new user account by collecting their information' }, + async (ctx): Promise => { + if (reqCtx.era === 'legacy') { + // 2025-era: push a server→client `elicitation/create` request and + // await the user's answer in-line. + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: 'Please provide your registration information:', + requestedSchema: REGISTRATION_SCHEMA + }); + if (result.action !== 'accept' || !result.content) { + return { content: [{ type: 'text', text: `registration ${result.action}` }] }; + } + const { username, email, plan } = result.content as Registration; + return { content: [{ type: 'text', text: `registered ${username} <${email}> (plan: ${plan ?? 'free'})` }] }; + } + // 2026-07-28: return inputRequired — the client collects the form + // and retries this same handler with the response attached. + const response = ctx.mcpReq.inputResponses?.['form'] as { action?: string } | undefined; + if (!response) { + return inputRequired({ + inputRequests: { + form: inputRequired.elicit({ + message: 'Please provide your registration information:', + requestedSchema: REGISTRATION_SCHEMA + }) + } + }); + } + const form = acceptedContent(ctx.mcpReq.inputResponses, 'form'); + if (!form) { + return { content: [{ type: 'text', text: `registration ${response.action}` }] }; + } + return { content: [{ type: 'text', text: `registered ${form.username} <${form.email}> (plan: ${form.plan ?? 'free'})` }] }; + } + ); + + // ---- Multi-step / chained form elicitation (two sequential prompts) ------ + server.registerTool( + 'plan_trip', + { description: 'Plan a trip by collecting a destination and then dates for that destination' }, + async (ctx): Promise => { + const DEST: ElicitRequestFormParams['requestedSchema'] = { + type: 'object', + properties: { destination: { type: 'string', title: 'Destination' } }, + required: ['destination'] + }; + const datesFor = (dest: string): ElicitRequestFormParams['requestedSchema'] => ({ + type: 'object', + properties: { + departure: { type: 'string', title: `Departure date for ${dest}`, format: 'date' }, + nights: { type: 'integer', title: 'Nights', minimum: 1, maximum: 30 } + }, + required: ['departure', 'nights'] + }); + if (reqCtx.era === 'legacy') { + // 2025-era: two sequential `elicitation/create` pushes inside one tool call. + const step1 = await ctx.mcpReq.elicitInput({ mode: 'form', message: 'Where to?', requestedSchema: DEST }); + if (step1.action !== 'accept' || !step1.content) { + return { content: [{ type: 'text', text: `trip ${step1.action}` }] }; + } + const dest = step1.content.destination as string; + const step2 = await ctx.mcpReq.elicitInput({ mode: 'form', message: 'When?', requestedSchema: datesFor(dest) }); + if (step2.action !== 'accept' || !step2.content) { + return { content: [{ type: 'text', text: `trip ${step2.action}` }] }; + } + return { + content: [ + { type: 'text', text: `trip planned: ${dest} on ${step2.content.departure} for ${step2.content.nights} nights` } + ] + }; + } + // 2026-07-28: two `inputRequired` rounds — the second carries the + // first answer back via `requestState` (an opaque server-minted + // string) so the chain survives the stateless retry. See ../mrtr/ + // for integrity-protecting `requestState` in production. + const dates = acceptedContent<{ departure: string; nights: number }>(ctx.mcpReq.inputResponses, 'dates'); + const destination = + ctx.mcpReq.requestState ?? acceptedContent<{ destination: string }>(ctx.mcpReq.inputResponses, 'dest')?.destination; + if (!destination) { + return inputRequired({ inputRequests: { dest: inputRequired.elicit({ message: 'Where to?', requestedSchema: DEST }) } }); + } + if (!dates) { + return inputRequired({ + requestState: destination, + inputRequests: { dates: inputRequired.elicit({ message: 'When?', requestedSchema: datesFor(destination) }) } + }); + } + return { content: [{ type: 'text', text: `trip planned: ${destination} on ${dates.departure} for ${dates.nights} nights` }] }; + } + ); + + // ---- URL-mode elicitation (push style + completion notification) --------- + server.registerTool( + 'link_account', + { + description: 'Link a third-party account by opening a sign-in URL', + inputSchema: z.object({ provider: z.string() }) + }, + async ({ provider }, ctx): Promise => { + if (reqCtx.era === 'legacy') { + // 2025-era push style: send `elicitation/create` (mode: 'url') + // and, in parallel, simulate the out-of-band callback that + // fires when the user finishes the URL flow by sending + // `notifications/elicitation/complete` for the same id. The + // client waits for that notification before answering accept. + const elicitationId = randomUUID(); + // Tie the completion notification to the in-flight request so on + // sessionful HTTP it travels over this POST's SSE response stream + // (rather than the standalone GET stream). + const notifyComplete = server.server.createElicitationCompletionNotifier(elicitationId, { + relatedRequestId: ctx.mcpReq.id + }); + setTimeout(() => void notifyComplete().catch(error => console.error('[server] complete notify failed:', error)), 50); + const params: ElicitRequestURLParams = { + mode: 'url', + message: `Sign in to ${provider} to link your account`, + url: `https://example.com/oauth/${encodeURIComponent(provider)}/authorize`, + elicitationId + }; + const result = await ctx.mcpReq.elicitInput(params); + return { content: [{ type: 'text', text: result.action === 'accept' ? `linked ${provider}` : `link ${result.action}` }] }; + } + // 2026-07-28: URL elicitation rides the multi-round-trip flow. No + // elicitationId / complete notification — correlation is the + // server's own state across retries. + const auth = ctx.mcpReq.inputResponses?.['auth'] as { action?: string } | undefined; + if (auth?.action !== 'accept') { + return inputRequired({ + inputRequests: { + auth: inputRequired.elicitUrl({ + message: `Sign in to ${provider} to link your account`, + url: `https://example.com/oauth/${encodeURIComponent(provider)}/authorize` + }) + } + }); + } + return { content: [{ type: 'text', text: `linked ${provider}` }] }; + } + ); + + // ---- URL-mode elicitation (throw style, 2025-era only) ------------------- + // The error-style signal: the tool THROWS `UrlElicitationRequiredError` + // (wire `-32042`); the client catches it as a typed error and reads + // `.elicitations`. There is no 2026-07-28 equivalent — a throw on that era + // fails loudly with a steer to `inputRequired.elicitUrl(...)`. + server.registerTool( + 'confirm_payment', + { + description: 'Confirm a payment via a browser flow (2025-era throw-style URL elicitation)', + inputSchema: z.object({ cartId: z.string() }) + }, + async ({ cartId }): Promise => { + throw new UrlElicitationRequiredError([ + { + mode: 'url', + message: 'Open the link to confirm payment', + url: `https://example.com/confirm-payment?cart=${encodeURIComponent(cartId)}`, + elicitationId: randomUUID() + } + ]); + } + ); + + return server; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + // --- modern (2026-07-28): per-request, strict so the sessionful arm owns ALL legacy traffic --- + const modern = toNodeHandler(createMcpHandler(buildServer, { legacy: 'reject' })); + + // --- legacy (2025): sessionful Streamable HTTP — push-style elicitation + // requires the session (client capabilities + bidirectional SSE stream) --- + const sessions = new Map(); + const handleLegacy = async (req: IncomingMessage, res: ServerResponse, body: unknown): Promise => { + const sid = req.headers['mcp-session-id'] as string | undefined; + if (sid && sessions.has(sid)) { + await sessions.get(sid)!.handleRequest(req, res, body); + } else if (!sid && isInitializeRequest(body)) { + const t = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: id => { + sessions.set(id, t); + } + }); + t.onclose = () => t.sessionId && sessions.delete(t.sessionId); + await buildServer({ era: 'legacy' } as McpRequestContext).connect(t); + await t.handleRequest(req, res, body); + } else { + res.writeHead(sid ? 404 : 400, { 'content-type': 'application/json' }).end( + JSON.stringify({ + jsonrpc: '2.0', + error: sid + ? { code: -32_001, message: 'Session not found' } + : { code: -32_000, message: 'Bad Request: Session ID required' }, + id: null + }) + ); + } + }; + + createServer((req, res) => { + void (async () => { + // Read the body once for the predicate and pass it forward. + let body: unknown; + if (req.method === 'POST') { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const raw = Buffer.concat(chunks).toString('utf8'); + try { + body = raw ? JSON.parse(raw) : undefined; + } catch { + body = undefined; + } + } + const probe = new globalThis.Request(`http://localhost${req.url ?? '/'}`, { + method: req.method, + headers: req.headers as Record + }); + await ((await isLegacyRequest(probe, body)) ? handleLegacy(req, res, body) : modern(req, res, body)); + })().catch(error => { + console.error('[server] request error:', error instanceof Error ? error.message : error); + if (!res.headersSent) res.writeHead(500).end(); + }); + }).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/eslint.config.mjs b/examples/eslint.config.mjs new file mode 100644 index 0000000000..08e956e733 --- /dev/null +++ b/examples/eslint.config.mjs @@ -0,0 +1,46 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [ + ...baseConfig, + { + // The nested workspace packages (shared, *-quickstart) are linted by their own configs. + // The one-way "@mcp-examples/shared must not import from stories" rule lives in + // shared/eslint.config.mjs so it fires under that package's own lint. + ignores: ['shared/**', 'server-quickstart/**', 'client-quickstart/**'] + }, + { + files: ['**/*.{ts,tsx,js,jsx,mts,cts}'], + rules: { + // Examples write to stdout/stderr deliberately. + 'no-console': 'off', + // Examples MUST use only what a consumer would `npm install` and import: + // public package entry points and the @mcp-examples/shared scaffold. Anything + // reaching into package internals or workspace source is banned. + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@modelcontextprotocol/*/src/*', '@modelcontextprotocol/*/dist/*'], + message: 'Examples must import only public package entry points (no /src/ or /dist/ deep paths).' + }, + { + group: ['**/packages/*', '../../packages/*', '../../../packages/*'], + message: 'Examples must not reach into workspace source.' + }, + { + group: ['@modelcontextprotocol/core', '@modelcontextprotocol/core/*'], + message: 'Examples must import from @modelcontextprotocol/{server,client}, not the internal core barrel.' + }, + { + group: ['@modelcontextprotocol/test-helpers', '@modelcontextprotocol/test-helpers/*'], + message: 'Examples must not depend on test helpers.' + } + ] + } + ] + } + } +]; diff --git a/examples/gateway/README.md b/examples/gateway/README.md new file mode 100644 index 0000000000..55e4a9c11a --- /dev/null +++ b/examples/gateway/README.md @@ -0,0 +1,50 @@ +# gateway + +`connect({ prior: DiscoverResult })` — zero-round-trip connect for gateways and distributed clients (protocol revision 2026-07-28). + +```bash +pnpm --filter @mcp-examples/gateway server -- --http --port 3000 +pnpm --filter @mcp-examples/gateway client -- --http http://127.0.0.1:3000/ +``` + +The 2026 protocol is **stateless on HTTP**: every request carries the per-request `_meta` envelope (protocol version, client info, client capabilities), so once you know the server's `DiscoverResult` there is nothing left to negotiate. A gateway, proxy, or worker fleet that +fronts the same server should not re-probe per worker — it probes once and every subsequent connect is free. + +## The pattern + +```ts +// 1. Bootstrap: probe once. +const bootstrap = new Client({ name: 'bootstrap', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); +await bootstrap.connect(new StreamableHTTPClientTransport(url)); +const persisted = JSON.stringify(bootstrap.getDiscoverResult()); // → write to Redis / config / process-local cache +await bootstrap.close(); + +// 2. Every worker: zero-round-trip connect from the persisted blob. +const worker = new Client({ name: 'worker', version: '1.0.0' }); +await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) }); +await worker.callTool({ name: 'echo', arguments: { text: 'hi' } }); // first wire traffic +``` + +`getDiscoverResult()` is populated by the `'auto'`/pinned probe path, by `client.discover()`, and by `connect({ prior })` itself. The value round-trips through `JSON.stringify`/`JSON.parse`. + +## What this story asserts + +The server exposes a `request_count` tool returning how many MCP requests reached the process (`createMcpHandler` builds one server instance per request). The client asserts: + +- after the bootstrap probe + one `request_count` call, the count is **2**; +- after three worker `connect({ prior })` calls + one `request_count` call, the count is **3** — proving the three connects sent **zero** requests; +- each worker can `callTool` immediately; +- after three `echo` calls + one `request_count` call, the count is **7**. + +## When to use `prior` + +- A gateway/proxy that holds a long-lived connection pool to one server and constructs a fresh `Client` per downstream request. +- A horizontally-scaled host where one worker's probe should seed the fleet (persist the blob to a shared cache). +- Reconnecting after a transient transport drop without re-probing. + +## Security: same-credential reuse only + +Only reuse a persisted `DiscoverResult` across workers that present the **same authorization context** as the bootstrap client (key the blob on a credential hash). Adopting a wider `prior` does not grant access — the server authorizes every request — but it can mislead +client-side capability gating. + +`connect({ prior })` is **modern-only**: no mutual 2026-07-28+ revision → `SdkError(EraNegotiationFailed)`. Use `versionNegotiation: { mode: 'auto' }` for legacy-era fallback. diff --git a/examples/gateway/client.ts b/examples/gateway/client.ts new file mode 100644 index 0000000000..b7824cb85a --- /dev/null +++ b/examples/gateway/client.ts @@ -0,0 +1,87 @@ +/** + * Gateway / distributed-client pattern: probe once, persist the + * `DiscoverResult`, feed it to every worker for a zero-round-trip connect. + * + * 1. A "bootstrap" client connects with `versionNegotiation: { mode: 'auto' }` + * — one `server/discover` round trip — and reads `getDiscoverResult()`. + * 2. The result is `JSON.stringify`-ed (the "persist" step — in a real gateway + * you would write this to Redis, a config map, or a process-local cache). + * 3. Three fresh worker clients connect with + * `connect(transport, { prior: JSON.parse(persisted) })`: each connect() + * sends nothing on the wire, and `callTool` works immediately. + * 4. The server's `request_count` tool proves it: after three worker connects + * the count is unchanged (no extra discover/initialize from the workers). + * + * **Security:** the persisted advertisement is what the server returned for the + * bootstrap client's credential. Only reuse it across workers that present the + * SAME authorization context — here every client speaks to the same + * unauthenticated endpoint, so the constraint holds trivially. Do not share a + * `DiscoverResult` across principals. + */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; +import type { DiscoverResult } from '@modelcontextprotocol/client'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +async function requestCount(client: Client): Promise { + const r = await client.callTool({ name: 'request_count' }); + return Number((r.content?.[0] as { text: string }).text); +} + +const { url } = parseExampleArgs(); + +// --------------------------------------------------------------------- +// Step 1: bootstrap — one server/discover probe. +// --------------------------------------------------------------------- +const bootstrap = new Client({ name: 'gateway-bootstrap', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); +await bootstrap.connect(new StreamableHTTPClientTransport(new URL(url))); +check.equal(bootstrap.getNegotiatedProtocolVersion(), '2026-07-28'); + +const discovered = bootstrap.getDiscoverResult(); +check.ok(discovered, 'bootstrap connect populated getDiscoverResult()'); +check.deepEqual(discovered?.serverInfo, { name: 'gateway-target', version: '1.0.0' }); + +// The probe was the only request so far; the request_count call is the +// second. (createMcpHandler builds one server instance per request.) +check.equal(await requestCount(bootstrap), 2); + +// --------------------------------------------------------------------- +// Step 2: persist. In a real gateway you'd write this to Redis / a config +// map / a process-local cache here. JSON round-trips by design. +// --------------------------------------------------------------------- +const persisted: string = JSON.stringify(discovered); +await bootstrap.close(); + +// --------------------------------------------------------------------- +// Step 3: three fresh workers connect from the persisted blob — zero +// round trips each. Every worker presents the same authorization context +// as the bootstrap (unauthenticated here), so reuse is safe. +// --------------------------------------------------------------------- +const prior: DiscoverResult = JSON.parse(persisted) as DiscoverResult; +const workers = await Promise.all( + ['worker-a', 'worker-b', 'worker-c'].map(async name => { + const worker = new Client({ name, version: '1.0.0' }); + await worker.connect(new StreamableHTTPClientTransport(new URL(url)), { prior }); + // Adopted directly from prior — no probe, no initialize. + check.equal(worker.getNegotiatedProtocolVersion(), '2026-07-28'); + check.deepEqual(worker.getServerVersion(), { name: 'gateway-target', version: '1.0.0' }); + return worker; + }) +); + +// --------------------------------------------------------------------- +// Step 4: prove it. Three connect() calls and the count is unchanged +// (still 2 from the bootstrap leg + this request_count call = 3). Had +// each worker probed/initialized, this would read 6. +// --------------------------------------------------------------------- +check.equal(await requestCount(workers[0]!), 3); + +// Each worker can callTool immediately. +for (const [i, worker] of workers.entries()) { + const echoed = await worker.callTool({ name: 'echo', arguments: { text: `hello from ${i}` } }); + check.equal((echoed.content?.[0] as { text: string }).text, `hello from ${i}`); +} + +// 3 (above) + 3 echo calls + this request_count call = 7. +check.equal(await requestCount(workers[0]!), 7); + +for (const worker of workers) await worker.close(); diff --git a/examples/gateway/package.json b/examples/gateway/package.json new file mode 100644 index 0000000000..676b5313e6 --- /dev/null +++ b/examples/gateway/package.json @@ -0,0 +1,26 @@ +{ + "name": "@mcp-examples/gateway", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "modern", + "//": "connect({ prior }) is zero-round-trip on 2026-07-28 only — the legacy era still needs initialize. HTTP-only because the proof counts per-request factory calls (createMcpHandler builds one server instance per request); on stdio the factory is per-connection." + } +} diff --git a/examples/gateway/server.ts b/examples/gateway/server.ts new file mode 100644 index 0000000000..ec0d657d27 --- /dev/null +++ b/examples/gateway/server.ts @@ -0,0 +1,49 @@ +/** + * Gateway / distributed-client target server. A plain 2026-era MCP server with + * a couple of tools and a `request_count` instrumentation tool that returns how + * many requests have reached this process — `createMcpHandler` builds one + * server instance per inbound request, so the module-level counter equals the + * number of MCP requests served (server/discover, tools/call, …). The client + * asserts against it to PROVE that `connect({ prior })` sent nothing. + */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +let requestCount = 0; + +function buildServer(): McpServer { + requestCount++; + const server = new McpServer({ name: 'gateway-target', version: '1.0.0' }); + + server.registerTool('echo', { description: 'Echo the input back', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] + })); + + server.registerTool('uppercase', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text: text.toUpperCase() }] + })); + + // Exposes the process-wide request count so the client can assert exactly + // which round trips happened. The factory increment for THIS call has + // already run by the time the handler executes, so the returned value + // includes the request_count call itself. + server.registerTool('request_count', { description: 'Number of MCP requests this server process has received' }, async () => ({ + content: [{ type: 'text', text: String(requestCount) }] + })); + + return server; +} + +// HTTP-only — the request_count proof depends on `createMcpHandler`'s +// per-request factory; on stdio the factory is per-connection and the 2/3/7 +// assertions would not hold. +const { port } = parseExampleArgs(); + +const handler = createMcpHandler(buildServer); +createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); +}); diff --git a/examples/guides/README.md b/examples/guides/README.md new file mode 100644 index 0000000000..d0ed7dbe93 --- /dev/null +++ b/examples/guides/README.md @@ -0,0 +1,3 @@ +# guides + +Snippet collections synced into `docs/server.md` and `docs/client.md` via `pnpm sync:snippets`. Typecheck-only — these are not runnable programs. diff --git a/examples/client/src/clientGuide.examples.ts b/examples/guides/clientGuide.examples.ts similarity index 73% rename from examples/client/src/clientGuide.examples.ts rename to examples/guides/clientGuide.examples.ts index 99a8383bc8..0ce10cca0f 100644 --- a/examples/client/src/clientGuide.examples.ts +++ b/examples/guides/clientGuide.examples.ts @@ -8,7 +8,15 @@ */ //#region imports -import type { AuthProvider, Prompt, Resource, Tool } from '@modelcontextprotocol/client'; +import type { + AuthProvider, + OAuthClientInformationContext, + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthClientProvider, + OAuthDiscoveryState, + OAuthTokens +} from '@modelcontextprotocol/client'; import { applyMiddlewares, Client, @@ -16,6 +24,7 @@ import { createMiddleware, CrossAppAccessProvider, discoverAndRequestJwtAuthGrant, + IssuerMismatchError, PrivateKeyJwtProvider, ProtocolError, SdkError, @@ -23,7 +32,8 @@ import { SSEClientTransport, StreamableHTTPClientTransport, TRACEPARENT_META_KEY, - TRACESTATE_META_KEY + TRACESTATE_META_KEY, + UnauthorizedError } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; //#endregion imports @@ -78,6 +88,33 @@ async function connect_sseFallback(url: string) { //#endregion connect_sseFallback } +/** Example: Opt into 2026-07-28 protocol version negotiation. */ +async function Client_versionNegotiation(transport: StreamableHTTPClientTransport) { + //#region Client_versionNegotiation + // Auto-negotiate: probe with server/discover, fall back to the 2025 handshake + // against a 2025-only server. + const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(transport); + + client.getProtocolEra(); // 'modern' or 'legacy' + client.getNegotiatedProtocolVersion(); // '2026-07-28' or '2025-11-25' + //#endregion Client_versionNegotiation +} + +/** Example: zero-round-trip connect from a persisted DiscoverResult. */ +async function Client_connect_prior(url: URL) { + //#region Client_connect_prior + // Probe once (here via the 'auto'-mode connect), persist the result … + const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await bootstrap.connect(new StreamableHTTPClientTransport(url)); + const persisted = JSON.stringify(bootstrap.getDiscoverResult()); + + // … then every worker connects with zero round trips. + const worker = new Client({ name: 'worker', version: '1.0.0' }); + await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) }); + //#endregion Client_connect_prior +} + // --------------------------------------------------------------------------- // Disconnecting // --------------------------------------------------------------------------- @@ -176,6 +213,119 @@ async function auth_crossAppAccess(getIdToken: () => Promise) { return transport; } +/** + * Example: Minimal `OAuthClientProvider` for the authorization-code flow. + * Client credentials are stored per authorization-server `issuer` (SEP-2352). + */ +function auth_oauthClientProvider(onRedirect: (url: URL) => void) { + //#region auth_oauthClientProvider + class MyOAuthProvider implements OAuthClientProvider { + // Key DCR-obtained credentials by issuer so a client_id registered with one + // authorization server is never returned for another (SEP-2352). + private creds = new Map(); + private storedTokens?: OAuthTokens; + private verifier?: string; + private discovery?: OAuthDiscoveryState; + lastState?: string; + + readonly redirectUrl = 'http://localhost:8090/callback'; + readonly clientMetadata: OAuthClientMetadata = { + client_name: 'My MCP Client', + redirect_uris: ['http://localhost:8090/callback'], + // Loopback redirect → the SDK would default this to 'native'; set + // explicitly when the heuristic is wrong for your deployment (SEP-837). + application_type: 'native' + }; + + clientInformation(ctx?: OAuthClientInformationContext) { + return ctx ? this.creds.get(ctx.issuer) : undefined; + } + saveClientInformation(info: OAuthClientInformationMixed, ctx?: OAuthClientInformationContext) { + if (ctx) this.creds.set(ctx.issuer, info); + } + tokens() { + return this.storedTokens; + } + saveTokens(tokens: OAuthTokens) { + // In production, persist to OS keychain / secure storage — never plain files. + this.storedTokens = tokens; + } + // CSRF binding for the redirect — the SDK puts this on the authorize URL; + // your callback handler compares it before calling `finishAuth`. + state() { + this.lastState = crypto.randomUUID(); + return this.lastState; + } + // Callback-leg AS-binding (SEP-2352): record what discovery resolved before + // the redirect so the SDK can verify the code is exchanged at the same AS. + saveDiscoveryState(state: OAuthDiscoveryState) { + this.discovery = state; + } + discoveryState() { + return this.discovery; + } + redirectToAuthorization(url: URL) { + onRedirect(url); + } + saveCodeVerifier(v: string) { + this.verifier = v; + } + codeVerifier() { + if (!this.verifier) throw new Error('no code verifier'); + return this.verifier; + } + } + + const provider = new MyOAuthProvider(); + const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { + authProvider: provider + }); + //#endregion auth_oauthClientProvider + return { provider, transport }; +} + +/** Example: Handling the OAuth callback — extract `iss` for RFC 9207 validation. */ +async function auth_finishAuth(url: URL, provider: OAuthClientProvider & { lastState?: string }, waitForCallback: () => Promise) { + //#region auth_finishAuth + const client = new Client({ name: 'my-client', version: '1.0.0' }); + const transport = new StreamableHTTPClientTransport(url, { authProvider: provider }); + try { + await client.connect(transport); + return client; + } catch (error) { + // With version negotiation, the connect-time 401 may surface wrapped as + // SdkError(EraNegotiationFailed) whose .data.cause is the UnauthorizedError. + const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause; + if (!(root instanceof UnauthorizedError)) throw error; + // The transport called redirectToAuthorization(); fall through to the browser callback. + } + + const callbackUrl = await waitForCallback(); + const params = new URL(callbackUrl).searchParams; + + // The SDK does not validate `state` — compare it to the value your provider generated. + if (params.get('state') !== provider.lastState) throw new Error('state mismatch'); + + try { + // Preferred: hand over the whole query — the SDK extracts `code` and + // `iss`, validates `iss` (RFC 9207), and never surfaces callback-derived + // `error`/`error_description` text on mismatch. + await transport.finishAuth(params); + } catch (error) { + if (error instanceof IssuerMismatchError) { + // Mix-up attack: do NOT render params.get('error_description') to the user. + throw new Error('Authorization failed: issuer mismatch'); + } + throw error; + } + + // Reconnect on a FRESH transport — a started transport cannot be restarted; + // OAuth state (tokens, verifier, discovery) lives on the provider, not the transport. + await client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider })); + return client; + //#endregion auth_finishAuth +} + // --------------------------------------------------------------------------- // Using server features // --------------------------------------------------------------------------- @@ -183,16 +333,10 @@ async function auth_crossAppAccess(getIdToken: () => Promise) { /** Example: List and call tools. */ async function callTool_basic(client: Client) { //#region callTool_basic - const allTools: Tool[] = []; - let toolCursor: string | undefined; - do { - const { tools, nextCursor } = await client.listTools({ cursor: toolCursor }); - allTools.push(...tools); - toolCursor = nextCursor; - } while (toolCursor); + const { tools } = await client.listTools(); console.log( 'Available tools:', - allTools.map(t => t.name) + tools.map(t => t.name) ); const result = await client.callTool({ @@ -211,9 +355,13 @@ async function callTool_structuredOutput(client: Client) { arguments: { weightKg: 70, heightM: 1.75 } }); - // Machine-readable output for the client application - if (result.structuredContent) { - console.log(result.structuredContent); // e.g. { bmi: 22.86 } + // Machine-readable output for the client application. SEP-2106: structuredContent is + // `unknown` (any JSON value). Check for presence with `!== undefined` and narrow before use. + if (result.structuredContent !== undefined) { + const sc: unknown = result.structuredContent; // e.g. { bmi: 22.86 } + if (typeof sc === 'object' && sc !== null && 'bmi' in sc) { + console.log(sc.bmi); + } } //#endregion callTool_structuredOutput } @@ -238,16 +386,10 @@ async function callTool_progress(client: Client) { /** Example: List and read resources. */ async function readResource_basic(client: Client) { //#region readResource_basic - const allResources: Resource[] = []; - let resourceCursor: string | undefined; - do { - const { resources, nextCursor } = await client.listResources({ cursor: resourceCursor }); - allResources.push(...resources); - resourceCursor = nextCursor; - } while (resourceCursor); + const { resources } = await client.listResources(); console.log( 'Available resources:', - allResources.map(r => r.name) + resources.map(r => r.name) ); const { contents } = await client.readResource({ uri: 'config://app' }); @@ -277,16 +419,10 @@ async function subscribeResource_basic(client: Client) { /** Example: List and get prompts. */ async function getPrompt_basic(client: Client) { //#region getPrompt_basic - const allPrompts: Prompt[] = []; - let promptCursor: string | undefined; - do { - const { prompts, nextCursor } = await client.listPrompts({ cursor: promptCursor }); - allPrompts.push(...prompts); - promptCursor = nextCursor; - } while (promptCursor); + const { prompts } = await client.listPrompts(); console.log( 'Available prompts:', - allPrompts.map(p => p.name) + prompts.map(p => p.name) ); const { messages } = await client.getPrompt({ @@ -599,6 +735,7 @@ async function resumptionToken_basic(client: Client) { void connect_streamableHttp; void connect_stdio; void connect_sseFallback; +void Client_versionNegotiation; void disconnect_streamableHttp; void serverInstructions_basic; void auth_tokenProvider; diff --git a/examples/server/src/serverGuide.examples.ts b/examples/guides/serverGuide.examples.ts similarity index 100% rename from examples/server/src/serverGuide.examples.ts rename to examples/guides/serverGuide.examples.ts diff --git a/examples/hono/README.md b/examples/hono/README.md new file mode 100644 index 0000000000..6a17754900 --- /dev/null +++ b/examples/hono/README.md @@ -0,0 +1,6 @@ +# hono + +`createMcpHandler(...).fetch` mounted on a Hono app — the web-standard face that runs on Cloudflare Workers, Deno, Bun and Node.js (via `@hono/node-server`). The `@modelcontextprotocol/hono` adapter (`createMcpHonoApp()`) arms localhost DNS-rebinding / origin protection by +default. + +**HTTP-only** by definition. diff --git a/examples/hono/client.ts b/examples/hono/client.ts new file mode 100644 index 0000000000..24dd8dd42f --- /dev/null +++ b/examples/hono/client.ts @@ -0,0 +1,25 @@ +/** + * Connects to the Hono-hosted server, lists tools and calls `greet`. + * + * HTTP-only — the point is the Hono adapter; a stdio leg would bypass it. + */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const { url, era } = parseExampleArgs(); + +// `createMcpHandler.fetch` serves both eras (default `'stateless'` posture); +// the runner drives `--legacy` to exercise the legacy negotiation path too. +const client = new Client( + { name: 'hono-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); + +await client.connect(new StreamableHTTPClientTransport(new URL(url))); + +const tools = await client.listTools(); +check.ok(tools.tools.some(t => t.name === 'greet')); +const result = await client.callTool({ name: 'greet', arguments: { name: 'hono' } }); +check.match(result.content?.[0]?.type === 'text' ? result.content[0].text : '', /Hello, hono!/); + +await client.close(); diff --git a/examples/hono/package.json b/examples/hono/package.json new file mode 100644 index 0000000000..139c26e13d --- /dev/null +++ b/examples/hono/package.json @@ -0,0 +1,28 @@ +{ + "name": "@mcp-examples/hono", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "dual", + "path": "/mcp", + "//": "createMcpHandler.fetch hosting is era-agnostic (default 'stateless' posture serves both)." + } +} diff --git a/examples/hono/server.ts b/examples/hono/server.ts new file mode 100644 index 0000000000..88d9e69d35 --- /dev/null +++ b/examples/hono/server.ts @@ -0,0 +1,37 @@ +/** + * Hosting on Hono / web-standard runtimes (Cloudflare Workers, Deno, Bun, + * Node.js via `@hono/node-server`). + * + * `createMcpHandler(...).fetch` is the web-standard face: pass the raw + * `Request` and return the `Response`. The `@modelcontextprotocol/hono` + * package adds the same DNS-rebinding / origin protection middleware the + * Express adapter ships. + * + * HTTP-only — the point is the Hono adapter; a stdio leg would bypass it. + */ +import { serve } from '@hono/node-server'; +import { parseExampleArgs } from '@mcp-examples/shared'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'hono-example', version: '1.0.0' }); + server.registerTool( + 'greet', + { title: 'Greeting Tool', description: 'A simple greeting tool', inputSchema: z.object({ name: z.string() }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}! (from Hono + createMcpHandler.fetch)` }] }) + ); + return server; +} + +const { port } = parseExampleArgs(); + +const handler = createMcpHandler(buildServer); +// `createMcpHonoApp()` arms localhost host/origin validation by default. +const app = createMcpHonoApp(); +app.get('/health', c => c.json({ status: 'ok' })); +app.all('/mcp', c => handler.fetch(c.req.raw)); +serve({ fetch: app.fetch, port }, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); +}); diff --git a/examples/json-response/README.md b/examples/json-response/README.md new file mode 100644 index 0000000000..b15f133209 --- /dev/null +++ b/examples/json-response/README.md @@ -0,0 +1,5 @@ +# json-response + +`createMcpHandler({ responseMode: 'json' })` — a single `application/json` body per request instead of an SSE stream. Useful for serverless / edge runtimes that can't hold a stream open. Mid-call notifications are dropped. + +**HTTP-only** by definition. diff --git a/examples/json-response/client.ts b/examples/json-response/client.ts new file mode 100644 index 0000000000..72189fe9c2 --- /dev/null +++ b/examples/json-response/client.ts @@ -0,0 +1,49 @@ +/** + * Asserts the `responseMode: 'json'` server answers a `tools/call` with a + * `Content-Type: application/json` body (not `text/event-stream`) AND that the + * regular `Client` works against it unchanged. + * + * HTTP-only, modern-only — `responseMode` shapes the 2026-07-28 per-request + * HTTP path; there is no stdio equivalent and 2025-era traffic goes through + * the stateless legacy fallback unaffected. + */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const { url } = parseExampleArgs(); + +const client = new Client({ name: 'json-response-example-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + +await client.connect(new StreamableHTTPClientTransport(new URL(url))); + +// Low-level: a 2026-07-28 (envelope) request should come back as plain JSON — +// the JSON content-type assertion is the point of the story. +const probe = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': 'tools/list' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'probe', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {} + } + } + }) +}); +check.match(probe.headers.get('content-type') ?? '', /application\/json/); +check.equal(probe.status, 200); + +// High-level: the regular Client works unchanged. +const result = await client.callTool({ name: 'greet', arguments: { name: 'json' } }); +check.equal(result.content?.[0]?.type === 'text' ? result.content[0].text : '', 'Hello, json!'); + +await client.close(); diff --git a/examples/json-response/package.json b/examples/json-response/package.json new file mode 100644 index 0000000000..4ec6e1c857 --- /dev/null +++ b/examples/json-response/package.json @@ -0,0 +1,26 @@ +{ + "name": "@mcp-examples/json-response", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "modern", + "//": "responseMode shapes the modern (2026-07-28) per-request path only; 2025-era traffic goes through the stateless legacy fallback unaffected, so a legacy leg would not exercise the option." + } +} diff --git a/examples/json-response/server.ts b/examples/json-response/server.ts new file mode 100644 index 0000000000..d954db09d1 --- /dev/null +++ b/examples/json-response/server.ts @@ -0,0 +1,34 @@ +/** + * `createMcpHandler` with `responseMode: 'json'` — single JSON response + * instead of an SSE stream. Useful for serverless deployments that can't + * hold a stream open. Mid-call notifications are dropped (the handler logs a + * warning at construction time). + * + * HTTP-only — `responseMode` shapes the HTTP response body; there is no stdio + * equivalent and a stdio leg would not exercise the option. + */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'json-response-example', version: '1.0.0' }); + server.registerTool( + 'greet', + { description: 'A simple greeting tool', inputSchema: z.object({ name: z.string() }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}!` }] }) + ); + return server; +} + +const { port } = parseExampleArgs(); + +// `responseMode: 'json'` is the point of this story — applies to the modern +// (2026-07-28) per-request HTTP path. +const handler = createMcpHandler(buildServer, { responseMode: 'json' }); +createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); +}); diff --git a/examples/legacy-routing/README.md b/examples/legacy-routing/README.md new file mode 100644 index 0000000000..21a2a38e9e --- /dev/null +++ b/examples/legacy-routing/README.md @@ -0,0 +1,26 @@ +# legacy-routing + +`isLegacyRequest` routing: keep an **existing** sessionful 1.x Streamable HTTP deployment serving 2025-era clients, add a strict `createMcpHandler({ legacy: 'reject' })` for 2026-07-28 traffic, on the **same port**. The predicate decides per request which arm handles it. + +`server.ts` also shows the browser-client CORS `exposedHeaders` recipe and explicit `GET` (standalone SSE stream) / `DELETE` (session termination per the MCP spec) routes for the sessionful arm. + +**HTTP-only** by definition; see also `dual-era/` for the simple case where you don't have a sessionful deployment to keep. + +## Direct transport construction (without `createMcpHandler`) + +If you need full control over the per-request transport on a web-standards runtime (Hono, Cloudflare Workers, …) instead of `createMcpHandler`, construct `WebStandardStreamableHTTPServerTransport` directly: + +```ts +import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; + +const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID() +}); +const server = new McpServer({ name: 'direct-transport', version: '1.0.0' }); +await server.connect(transport); + +// Any Request/Response runtime (fetch handler, Hono `c.req.raw`, …): +export default { fetch: (request: Request) => transport.handleRequest(request) }; +``` + +`NodeStreamableHTTPServerTransport` (used in this story's legacy arm) is the Node.js `IncomingMessage`/`ServerResponse` equivalent. diff --git a/examples/legacy-routing/client.ts b/examples/legacy-routing/client.ts new file mode 100644 index 0000000000..2df6a8e107 --- /dev/null +++ b/examples/legacy-routing/client.ts @@ -0,0 +1,24 @@ +/** + * Connects to the routing fork as both a plain 2025 client (lands on the + * existing sessionful transport, `era=legacy`) and a 2026-capable client + * (lands on the strict modern entry, `era=modern`). + */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const { url } = parseExampleArgs(); + +// 2025 client → routed to the existing sessionful deployment. +const legacy = new Client({ name: 'legacy-routing-client', version: '1.0.0' }); +await legacy.connect(new StreamableHTTPClientTransport(new URL(url))); +const lr = await legacy.callTool({ name: 'greet', arguments: { name: 'A' } }); +check.match(lr.content?.[0]?.type === 'text' ? lr.content[0].text : '', /era=legacy/); +await legacy.close(); + +// 2026 client → routed to the strict modern entry. +const modern = new Client({ name: 'legacy-routing-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); +await modern.connect(new StreamableHTTPClientTransport(new URL(url))); +check.equal(modern.getNegotiatedProtocolVersion(), '2026-07-28'); +const mr = await modern.callTool({ name: 'greet', arguments: { name: 'B' } }); +check.match(mr.content?.[0]?.type === 'text' ? mr.content[0].text : '', /era=modern/); +await modern.close(); diff --git a/examples/legacy-routing/package.json b/examples/legacy-routing/package.json new file mode 100644 index 0000000000..408cc248a2 --- /dev/null +++ b/examples/legacy-routing/package.json @@ -0,0 +1,30 @@ +{ + "name": "@mcp-examples/legacy-routing", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "cors": "catalog:runtimeServerOnly", + "express": "catalog:runtimeServerOnly", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "@types/cors": "catalog:devTools", + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "modern", + "//": "The story body drives BOTH eras itself (one legacy + one modern client against the same port); pinned so the runner invokes it once." + } +} diff --git a/examples/legacy-routing/server.ts b/examples/legacy-routing/server.ts new file mode 100644 index 0000000000..efc0f88e02 --- /dev/null +++ b/examples/legacy-routing/server.ts @@ -0,0 +1,93 @@ +/** + * `isLegacyRequest` routing in front of an existing sessionful 1.x deployment, + * with a strict modern entry on the SAME port. + * + * This is the v2 answer to "I already have a sessionful Streamable HTTP + * deployment and want to add 2026-07-28 serving without disturbing it": + * route in user land — `await isLegacyRequest(req)` decides per request, + * legacy traffic goes to your existing transport, modern traffic to a strict + * `createMcpHandler(factory, { legacy: 'reject' })`. + * + * HTTP-only by definition. + */ +import { randomUUID } from 'node:crypto'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport, toNodeHandler } from '@modelcontextprotocol/node'; +import type { McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, isInitializeRequest, isLegacyRequest, McpServer } from '@modelcontextprotocol/server'; +import cors from 'cors'; +import type { Request, Response } from 'express'; +import * as z from 'zod/v4'; + +// One factory for both legs. +const buildServer = (era: 'legacy' | 'modern') => { + const server = new McpServer({ name: 'legacy-routing-example', version: '1.0.0' }); + server.registerTool('greet', { description: 'Greets the caller', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}! (era=${era})` }] + })); + return server; +}; + +// --- the existing sessionful 2025 deployment, unchanged --- +const sessions = new Map(); +const handleLegacy = async (req: Request, res: Response) => { + const sid = req.headers['mcp-session-id'] as string | undefined; + if (sid && sessions.has(sid)) { + await sessions.get(sid)!.handleRequest(req, res, req.body); + } else if (!sid && isInitializeRequest(req.body)) { + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: id => { + sessions.set(id, transport); + } + }); + transport.onclose = () => transport.sessionId && sessions.delete(transport.sessionId); + await buildServer('legacy').connect(transport); + await transport.handleRequest(req, res, req.body); + } else if (sid) { + // Unknown session ID → 404 so the client knows to start a new session. + res.status(404).json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }); + } else { + res.status(400).json({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: Session ID required' }, id: null }); + } +}; + +// --- the strict modern entry alongside it --- +const modern = createMcpHandler((ctx: McpRequestContext) => buildServer(ctx.era), { legacy: 'reject' }); +const modernNode = toNodeHandler(modern); + +const app = createMcpExpressApp(); +// Browser-client CORS recipe: expose the response headers a browser-based MCP +// client must be able to read (`Mcp-Session-Id` for session correlation, +// `WWW-Authenticate` for the auth challenge, `Last-Event-Id` for resumability, +// `Mcp-Protocol-Version` for negotiation). DEMO ONLY — restrict `origin` in +// production. +app.use( + cors({ + origin: '*', + exposedHeaders: ['Mcp-Session-Id', 'WWW-Authenticate', 'Last-Event-Id', 'Mcp-Protocol-Version'] + }) +); + +app.post('/mcp', async (req: Request, res: Response) => { + // The predicate inspects the same headers + body the entry does. Express + // has parsed the JSON body; pass it as `parsedBody` so the predicate need + // not re-read the stream. + const probe = new globalThis.Request(`http://localhost${req.url}`, { + method: req.method, + headers: req.headers as Record + }); + await ((await isLegacyRequest(probe, req.body)) ? handleLegacy(req, res) : modernNode(req, res, req.body)); +}); +// GET (standalone SSE stream / reconnect with Last-Event-ID) and DELETE +// (explicit session termination per the MCP spec) are sessionful-2025-only — +// route them straight to the legacy arm; the transport handles each verb. +app.get('/mcp', (req, res) => void handleLegacy(req, res)); +app.delete('/mcp', (req, res) => void handleLegacy(req, res)); + +const { port } = parseExampleArgs(); +app.listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); +}); diff --git a/examples/mrtr/README.md b/examples/mrtr/README.md new file mode 100644 index 0000000000..d2ef926de6 --- /dev/null +++ b/examples/mrtr/README.md @@ -0,0 +1,10 @@ +# mrtr (multi-round-trip requests) + +A write-once `deploy` tool that requests client input by **returning** `inputRequired(...)` instead of pushing a server→client request (protocol revision 2026-07-28). State between rounds is carried in `requestState`, which the example HMAC-protects and verifies via the +`ServerOptions.requestState.verify` hook (a wire-level `-32602` on tamper). + +The client drives both the default auto-fulfilment mode (your existing `elicitation/create` handler is dispatched for you and `callTool()` returns a plain `CallToolResult`) and manual mode (`autoFulfill: false` + `allowInputRequired: true`). + +```bash +pnpm tsx examples/mrtr/client.ts +``` diff --git a/examples/mrtr/client.ts b/examples/mrtr/client.ts new file mode 100644 index 0000000000..1938f61174 --- /dev/null +++ b/examples/mrtr/client.ts @@ -0,0 +1,89 @@ +/** + * Drives the multi-round-trip server (`./server.ts`) two ways on a 2026-07-28 + * connection: + * + * 1. **auto-fulfilment** (the default) — the same `elicitation/create` + * handler the client would register for the 2025-era flow fulfils the + * embedded form and URL elicitations, and the SDK retries the original + * `tools/call` for you. `client.callTool()` returns a plain + * `CallToolResult`; + * 2. **manual mode** — `inputRequired: { autoFulfill: false }` plus per-call + * `allowInputRequired: true`: the input-required value is handed back, and + * the example collects responses, echoes `requestState`, and retries + * itself. + * + * Asserts both flows reach `deployed to …` and exits 0. + */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import type { CallToolResult, ClientOptions, InputRequiredResult } from '@modelcontextprotocol/client'; +import { Client, isInputRequiredResult, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const { transport, url, era } = parseExampleArgs(); + +// Both halves connect identically and differ only in ClientOptions; the +// local helper keeps the SDK transport setup visible in THIS file (the +// canonical shape) while avoiding duplicating it for each half. +const connect = async (options: ClientOptions): Promise => { + const client = new Client( + { name: 'mrtr-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' }, ...options } + ); + await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + return client; +}; + +// --- auto-fulfilment (the default) --- +const auto = await connect({ capabilities: { elicitation: { form: {}, url: {} } } }); +// The SAME handler a 2025-flow client registers: the auto-fulfilment +// engine dispatches embedded form and URL elicitations through it. +auto.setRequestHandler('elicitation/create', async request => { + const params = request.params as { mode?: string; message: string; url?: string }; + if (params.mode === 'url') return { action: 'accept' }; + return { action: 'accept', content: { confirm: true } }; +}); +// callTool returns a plain CallToolResult — the interactive rounds happen +// inside the call. +const autoResult = await auto.callTool({ name: 'deploy', arguments: { env: 'prod' } }); +const autoText = autoResult.content?.[0]?.type === 'text' ? autoResult.content[0].text : ''; +check.equal(autoText, 'deployed to prod'); +await auto.close(); + +// --- manual mode (autoFulfill: false + allowInputRequired) --- +const manual = await connect({ + capabilities: { elicitation: { form: {}, url: {} } }, + inputRequired: { autoFulfill: false } +}); +let inputResponses: Record | undefined; +let requestState: string | undefined; +let final: CallToolResult | undefined; +for (let round = 0; round < 10; round++) { + const value = (await manual.request( + { + method: 'tools/call', + params: { + name: 'deploy', + arguments: { env: 'staging' }, + ...(inputResponses && { inputResponses }), + ...(requestState && { requestState }) + } + }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + if (!isInputRequiredResult(value)) { + final = value; + break; + } + // Collect responses and echo requestState byte-exact. + inputResponses = {}; + for (const [key, entry] of Object.entries(value.inputRequests ?? {})) { + inputResponses[key] = entry.method === 'elicitation/create' ? { action: 'accept', content: { confirm: true } } : {}; + } + requestState = value.requestState; +} +check.ok(final, 'manual flow should reach a CallToolResult within 10 rounds'); +const manualText = final?.content?.[0]?.type === 'text' ? final.content[0].text : ''; +check.equal(manualText, 'deployed to staging'); +await manual.close(); diff --git a/examples/mrtr/package.json b/examples/mrtr/package.json new file mode 100644 index 0000000000..eca6285a84 --- /dev/null +++ b/examples/mrtr/package.json @@ -0,0 +1,23 @@ +{ + "name": "@mcp-examples/mrtr", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "modern", + "//": "Multi-round-trip inputRequired is a 2026-07-28 protocol feature." + } +} diff --git a/examples/mrtr/server.ts b/examples/mrtr/server.ts new file mode 100644 index 0000000000..c06568241f --- /dev/null +++ b/examples/mrtr/server.ts @@ -0,0 +1,119 @@ +/** + * A write-once tool that requests client input with multi-round-trip results + * (protocol revision 2026-07-28). + * + * The `deploy` tool returns `inputRequired(...)` instead of pushing a + * server→client request: a form-mode elicitation for confirmation, then a + * URL-mode elicitation for sign-in via `inputRequired.elicitUrl(...)`. The + * step the tool is waiting for is carried in `requestState`, which the SDK + * round-trips opaquely (echoed byte-exact by the client; the server reads it + * raw at `ctx.mcpReq.requestState`). + * + * `requestState` round-trips through the client and is therefore + * attacker-controlled input on re-entry. A real server MUST integrity-protect + * it (e.g. HMAC or AEAD): this example uses the SDK-provided + * {@linkcode createRequestStateCodec} helper — `mint` HMAC-seals the payload + * with a per-process key and a TTL, and `verify` is dropped directly into the + * {@linkcode ServerOptions.requestState} hook so the seam rejects tampered or + * expired state with a wire-level `-32602` Invalid Params error before the + * handler runs. + * + * One binary, either transport — selected by `--http --port ` (defaults to + * stdio). See `examples/CONTRIBUTING.md` for the canonical shape. + */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/server'; +import { acceptedContent, createMcpHandler, createRequestStateCodec, inputRequired, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +const CONFIRM_SCHEMA = { type: 'object' as const, properties: { confirm: { type: 'boolean' as const } }, required: ['confirm'] }; + +type DeployState = { step: 'confirm' | 'signed-in'; env: string }; + +// Per-process integrity key for requestState. The 2026-07-28 path serves every +// request from a fresh server instance — the state itself is the only thing +// that survives between rounds — so the key is process-local. A multi-instance +// deployment would load a shared secret here instead. +const stateCodec = createRequestStateCodec({ + key: crypto.getRandomValues(new Uint8Array(32)), + ttlSeconds: 600 +}); + +function buildServer(): McpServer { + const server = new McpServer( + { name: 'mrtr-example-server', version: '1.0.0' }, + { capabilities: { tools: {} }, requestState: { verify: stateCodec.verify } } + ); + + server.registerTool( + 'deploy', + { + title: 'Deploy (write-once)', + description: 'Deploys to the named environment after a confirmation and a sign-in.', + inputSchema: z.object({ env: z.string() }) + }, + async ({ env }, ctx): Promise => { + // The handler reads the SAME context fields on every entry; what + // changes between rounds is which input responses have arrived and + // what (verified) `requestState` was echoed back. The seam-level + // verify hook has already proven integrity by the time the handler + // runs; calling `verify` again here just yields the payload. + const state = ctx.mcpReq.requestState === undefined ? undefined : await stateCodec.verify(ctx.mcpReq.requestState, ctx); + const step = state?.step ?? 'confirm'; + console.error(`[server] tools/call deploy(${env}) step=${step}`); + + if (step === 'confirm') { + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (!confirmed?.confirm) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ message: `Deploy to ${env}?`, requestedSchema: CONFIRM_SCHEMA }) + }, + // The next entry stays at the 'confirm' step until the + // user actually accepts. + requestState: await stateCodec.mint({ step: 'confirm', env }) + }); + } + // Move to the URL-mode sign-in step. URL elicitation rides + // the multi-round-trip flow on this revision — the throw-style + // UrlElicitationRequiredError of earlier revisions is not + // available toward 2026-07-28 requests. + return inputRequired({ + inputRequests: { + auth: inputRequired.elicitUrl({ + message: 'Sign in to continue', + url: `https://example.com/auth?env=${env}` + }) + }, + requestState: await stateCodec.mint({ step: 'signed-in', env }) + }); + } + + // step === 'signed-in': the URL-mode elicitation completed out of + // band — verify the auth response actually arrived. + const auth = ctx.mcpReq.inputResponses?.['auth'] as { action?: string } | undefined; + if (auth?.action !== 'accept') { + return { isError: true, content: [{ type: 'text', text: 'auth response missing or declined' }] }; + } + return { content: [{ type: 'text', text: `deployed to ${state?.env ?? env}` }] }; + } + ); + + return server; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/oauth-client-credentials/README.md b/examples/oauth-client-credentials/README.md new file mode 100644 index 0000000000..5910ed00e0 --- /dev/null +++ b/examples/oauth-client-credentials/README.md @@ -0,0 +1,41 @@ +# oauth-client-credentials + +OAuth 2.0 **`client_credentials`** grant — machine-to-machine MCP auth, fully self-verifying with no browser. + +`client_credentials` is the grant a backend service uses to authenticate **as itself** (not on behalf of a user): it presents a pre-registered `client_id`/`client_secret` directly to the Authorization Server's token endpoint and receives a Bearer access token. There is no +redirect, no authorization code, no user consent screen. + +The interactive **authorization-code** flow (the one that opens a browser and asks a human to sign in) lives under [`../oauth/`](../oauth/README.md); the runner drives it headlessly via the demo AS's `OAUTH_DEMO_AUTO_CONSENT=1` auto-approve mode. + +## What runs + +- `server.ts` starts two listeners in one process: + - the MCP **resource server** on `--port` — `createMcpHandler` behind `requireBearerAuth` from `@modelcontextprotocol/express`, advertising the AS via `mcpAuthMetadataRouter` (RFC 9728 + RFC 8414). + - a minimal **`client_credentials`-only Authorization Server** on `--port + 1` (`createClientCredentialsAuthServer` from `@mcp-examples/shared`). The repo's full better-auth/OIDC demo AS only implements `authorization_code`, so this story ships its own purpose-built AS. +- `client.ts` first asserts a bare request is `401` with a `WWW-Authenticate` challenge, then connects with a `ClientCredentialsProvider` on the transport. The SDK auth driver discovers the AS from the challenge, posts `grant_type=client_credentials` (HTTP Basic auth) to + `/token`, attaches the returned Bearer token, and the `whoami` tool's `ctx.authInfo` carries the granted `clientId` and `scopes` end to end. + +## Run it + +```bash +pnpm --filter @mcp-examples/oauth-client-credentials server -- --http --port 3000 +pnpm --filter @mcp-examples/oauth-client-credentials client -- --http http://127.0.0.1:3000/mcp +``` + +HTTP-only; runs on both protocol eras (the client honours `--legacy` via `parseExampleArgs().era`). + +## `private_key_jwt` client authentication + +To authenticate the `client_credentials` grant with a signed JWT assertion (RFC 7523 §2.2) instead of a shared secret, swap `ClientCredentialsProvider` for `PrivateKeyJwtProvider`: + +```ts +import { PrivateKeyJwtProvider } from '@modelcontextprotocol/client'; + +const authProvider = new PrivateKeyJwtProvider({ + clientId: 'my-service', + privateKey: pemEncodedKey, + algorithm: 'RS256' +}); +``` + +The full snippet lives in the client guide (`docs/client.md` → `auth_privateKeyJwt`). There is no runnable leg for it in this story — the in-repo `client_credentials` AS only implements `client_secret_basic`/`client_secret_post`. diff --git a/examples/oauth-client-credentials/client.ts b/examples/oauth-client-credentials/client.ts new file mode 100644 index 0000000000..3d40654fac --- /dev/null +++ b/examples/oauth-client-credentials/client.ts @@ -0,0 +1,58 @@ +/** + * Self-verifying `client_credentials` client. + * + * 1. A bare request is `401` with a `WWW-Authenticate` challenge that names the + * Protected Resource Metadata URL. + * 2. A `Client` with a {@linkcode ClientCredentialsProvider} on its transport + * follows that challenge → AS metadata → `POST /token` with + * `grant_type=client_credentials` (HTTP Basic `client_id:client_secret`) → + * Bearer token → reaches the `whoami` tool, whose `ctx.authInfo` carries the + * granted scopes. + * + * No browser, no readline. The SDK's auth driver does the discovery; the only + * thing the caller supplies is the pre-registered client's id+secret. + */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; +import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const { url, era } = parseExampleArgs(); + +// Unauthenticated → 401 + WWW-Authenticate naming the PRM URL. +const unauth = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }) +}); +check.equal(unauth.status, 401, 'bare request must be 401'); +check.match(unauth.headers.get('www-authenticate') ?? '', /Bearer/); +check.match(unauth.headers.get('www-authenticate') ?? '', /oauth-protected-resource/); + +// Authenticated via client_credentials → 200, ctx.authInfo carries the granted scopes. +const provider = new ClientCredentialsProvider({ + clientId: 'demo-m2m-client', + clientSecret: 'demo-m2m-secret', + scope: 'mcp:tools mcp:read' +}); +const client = new Client( + { name: 'client-credentials-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); +await client.connect(new StreamableHTTPClientTransport(new URL(url), { authProvider: provider })); + +const tokens = provider.tokens(); +check.ok(tokens?.access_token, 'ClientCredentialsProvider obtained an access_token'); +check.equal(tokens?.token_type, 'Bearer'); + +const result = await client.callTool({ name: 'whoami', arguments: {} }); +const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''; +const seen = JSON.parse(text) as { clientId: string; scopes: string[] }; +check.equal(seen.clientId, 'demo-m2m-client', 'ctx.authInfo.clientId round-trips'); +check.ok(seen.scopes.includes('mcp:tools'), 'ctx.authInfo.scopes carries the granted scope'); + +// Expiry: both the demo verifier and `requireBearerAuth` reject when +// `AuthInfo.expiresAt` is in the past, so an expired token would 401 here +// exactly like the bare-request leg above. Minting an expired token would +// mean reaching past the AS's public surface, so the path is documented +// rather than exercised. + +await client.close(); diff --git a/examples/oauth-client-credentials/package.json b/examples/oauth-client-credentials/package.json new file mode 100644 index 0000000000..cd61ebf69c --- /dev/null +++ b/examples/oauth-client-credentials/package.json @@ -0,0 +1,28 @@ +{ + "name": "@mcp-examples/oauth-client-credentials", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "dual", + "path": "/mcp", + "//": "OAuth client_credentials is HTTP-layer and era-agnostic; the client honours --legacy via parseExampleArgs().era." + } +} diff --git a/examples/oauth-client-credentials/server.ts b/examples/oauth-client-credentials/server.ts new file mode 100644 index 0000000000..f986645d68 --- /dev/null +++ b/examples/oauth-client-credentials/server.ts @@ -0,0 +1,77 @@ +/** + * OAuth 2.0 **`client_credentials`** grant — the machine-to-machine story. + * + * One process hosts both halves on adjacent ports: + * + * - `:PORT` — the MCP **Resource Server**: `createMcpHandler` behind + * `requireBearerAuth`, advertising the AS via `mcpAuthMetadataRouter` + * (RFC 9728 Protected Resource Metadata + RFC 8414 AS metadata). + * - `:PORT+1` — a minimal in-repo **Authorization Server** that supports the + * `client_credentials` grant only (`@mcp-examples/shared`'s + * `createClientCredentialsAuthServer`). The full better-auth/OIDC demo AS + * only implements `authorization_code`, hence this purpose-built one. + * + * The client (`./client.ts`) discovers the AS from a 401 challenge, exchanges + * its `client_id`/`client_secret` for a Bearer token at `/token`, and reaches + * the `whoami` tool — which echoes `ctx.authInfo` so the client can assert the + * granted scopes round-tripped end to end. HTTP-only by definition. + */ +import { parseExampleArgs } from '@mcp-examples/shared'; +import { createClientCredentialsAuthServer } from '@mcp-examples/shared/auth'; +import { + createMcpExpressApp, + getOAuthProtectedResourceMetadataUrl, + mcpAuthMetadataRouter, + requireBearerAuth +} from '@modelcontextprotocol/express'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const { port } = parseExampleArgs(); +const AUTH_PORT = port + 1; +// 127.0.0.1 (not `localhost`) so the PRM `resource` value matches the URL the +// runner passes the client byte-for-byte — the SDK auth driver enforces that. +const mcpServerUrl = new URL(`http://127.0.0.1:${port}/mcp`); +const authServerUrl = new URL(`http://127.0.0.1:${AUTH_PORT}/`); + +// Demo confidential client. DEMO ONLY — never hard-code real credentials. +export const DEMO_CLIENT = { clientId: 'demo-m2m-client', clientSecret: 'demo-m2m-secret', allowedScopes: ['mcp:tools', 'mcp:read'] }; + +// ---- Authorization Server (client_credentials only) ---- +const as = createClientCredentialsAuthServer({ authServerUrl, clients: [DEMO_CLIENT] }); +as.app.listen(AUTH_PORT, () => console.error(`[auth-server] client_credentials AS on ${authServerUrl.href}`)); + +// ---- Resource Server (MCP) ---- +const handler = createMcpHandler(ctx => { + const server = new McpServer({ name: 'oauth-client-credentials-example', version: '1.0.0' }); + server.registerTool( + 'whoami', + { description: 'Returns the authenticated client and its granted scopes.', inputSchema: z.object({}) }, + async () => ({ + content: [{ type: 'text', text: JSON.stringify({ clientId: ctx.authInfo?.clientId, scopes: ctx.authInfo?.scopes }) }] + }) + ); + return server; +}); + +const app = createMcpExpressApp(); +app.use( + mcpAuthMetadataRouter({ + oauthMetadata: as.metadata, + resourceServerUrl: mcpServerUrl, + scopesSupported: ['mcp:tools', 'mcp:read'], + resourceName: 'oauth-client-credentials example' + }) +); +const auth = requireBearerAuth({ + verifier: as.verifier, + requiredScopes: ['mcp:tools'], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) +}); +// `requireBearerAuth` sets `req.auth`; `toNodeHandler` reads it and passes it +// to the factory as `ctx.authInfo`. +const node = toNodeHandler(handler); +app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); + +app.listen(port, () => console.error(`[resource-server] MCP on ${mcpServerUrl.href}`)); diff --git a/examples/oauth/README.md b/examples/oauth/README.md new file mode 100644 index 0000000000..0514c0c477 --- /dev/null +++ b/examples/oauth/README.md @@ -0,0 +1,28 @@ +# oauth + +The **authorization-code** OAuth grant — the interactive "user signs in and approves" flow — against an in-repo OAuth-protected MCP server. + +- `server.ts` — `setupAuthServer` (the better-auth/OIDC demo Authorization Server from `@mcp-examples/shared`) on `:PORT+1`, and a `createMcpHandler` Resource Server behind `requireBearerAuth({ verifier: demoTokenVerifier })` on `:PORT/mcp`, advertising the AS via + `createProtectedResourceMetadataRouter` (RFC 9728). DEMO ONLY — the AS auto-signs-in a fixed user, and with `OAUTH_DEMO_AUTO_CONSENT=1` it also auto-approves the consent screen. +- `client.ts` — **CI-runnable headless flow.** Drives the same SDK auth machinery as the browser client, but instead of `open()`ing the authorization URL it follows the 302 chain itself with `fetch(..., { redirect: 'manual' })` (the demo AS's auto-sign-in + auto-consent collapse + every interactive step into a redirect), reads the callback query off the final `Location` header, calls `transport.finishAuth(url.searchParams)` (so the SDK reads `code` + `iss` per RFC 9207), reconnects, and asserts `ctx.authInfo` round-trips. This is what `pnpm run:examples` runs. +- `simpleOAuthClient.ts` + `simpleOAuthClientProvider.ts` — **manual real-browser flow.** Full authorization-code flow against any OAuth-protected MCP server: opens the browser, runs a local callback server on `:8090`, exchanges the code, then drops into a small `list`/`call` + REPL. Run this when you want to see the consent page. +- `dualModeAuth.ts` — two auth patterns through the one `authProvider` option: host-managed bearer token vs a built-in `OAuthClientProvider`. +- `simpleTokenProvider.ts` — the minimal `AuthProvider` (just `token()`) for externally-managed bearer tokens. + +## Run it + +```bash +# headless (what CI does) — terminal 1: AS (:3001) + protected RS (:3000/mcp), auto-consent on +OAUTH_DEMO_AUTO_CONSENT=1 pnpm --filter @mcp-examples/oauth server +# terminal 2: follows the 302 chain, exchanges the code, asserts whoami +pnpm --filter @mcp-examples/oauth client -- --http http://127.0.0.1:3000/mcp + +# manual real-browser flow — terminal 1: same server (auto-consent optional) +pnpm --filter @mcp-examples/oauth server +# terminal 2: opens a browser to the demo AS, callback server on :8090, then a list/call REPL +pnpm --filter @mcp-examples/oauth client:browser +``` + +For the headless bearer-token resource-server case see `../bearer-auth/`; for the machine-to-machine `client_credentials` grant see `../oauth-client-credentials/`; for URL-mode elicitation see `../elicitation/`; for the interactive readline playground see `../repl/`. diff --git a/examples/oauth/client.ts b/examples/oauth/client.ts new file mode 100644 index 0000000000..330ae0b247 --- /dev/null +++ b/examples/oauth/client.ts @@ -0,0 +1,159 @@ +/** + * Self-verifying **authorization-code** OAuth client — the CI-runnable headless + * twin of {@link ./simpleOAuthClient.ts}. + * + * `simpleOAuthClient.ts` is the manual real-user example: it `open()`s the + * authorization URL in a real browser, the user signs in and clicks **Approve** + * on the consent screen, the browser is redirected to a local callback server, + * and the example reads the `code` off that callback. THIS file drives the + * exact same SDK auth machinery but follows the redirect chain itself — which + * only works because the demo Authorization Server is started with + * `OAUTH_DEMO_AUTO_CONSENT=1` so its `/sign-in` page auto-signs-in a fixed + * demo user and its `/authorize` endpoint auto-consents (skips the Approve + * screen and 302s straight back to `redirect_uri?code=...`). + * + * Flow: + * 1. Connect with an {@linkcode InMemoryOAuthClientProvider} → 401 → SDK auth + * driver discovers PRM → AS metadata → registers a client (DCR) → builds + * the authorization URL → calls our `redirectToAuthorization` hook (we + * capture the URL) → throws {@linkcode UnauthorizedError}. + * 2. Follow that URL with `fetch(..., { redirect: 'manual' })`, forwarding + * `Set-Cookie` → `Cookie` across hops, until the AS 302s to our + * `redirect_uri` with `?code=...`. No callback server is bound — the code + * is read straight off the `Location` header. + * 3. `transport.finishAuth(code)` → SDK exchanges the code (+ PKCE verifier) + * for tokens at the AS `/token` endpoint and saves them on the provider. + * 4. Reconnect with a fresh transport (same provider, now holding tokens) → + * Bearer header → 200. Call `whoami` and assert `ctx.authInfo` round-trips. + * + * HTTP-only (the OAuth dance is HTTP redirects + Bearer headers), so the + * canonical stdio branch does not apply. + */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; +import type { OAuthClientMetadata } from '@modelcontextprotocol/client'; +import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client'; + +import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; + +// The redirect target the AS will 302 back to with `?code=...`. In the real +// browser flow (`simpleOAuthClient.ts`) a tiny HTTP server listens here so the +// browser has somewhere to land; headlessly we never bind it — we read the +// `code` off the final 302's `Location` header instead. +const CALLBACK_URL = 'http://127.0.0.1:8090/callback'; + +/** + * Follow an authorization URL through the demo AS's redirect chain + * (authorize → /sign-in → authorize → redirect_uri?code=...) and return the + * `code`. This is the headless stand-in for "the user's browser navigates the + * login + consent pages": cookies are forwarded hop-to-hop the way a browser + * would, and the demo AS's auto-sign-in + `autoConsent` collapse every + * interactive step into a 302. + */ +async function followAuthorizationRedirects(authorizationUrl: URL): Promise { + let next = authorizationUrl.href; + // Crude cookie jar — enough for a single-origin demo AS. + const jar = new Map(); + for (let hop = 0; hop < 10; hop++) { + const cookie = [...jar].map(([k, v]) => `${k}=${v}`).join('; '); + // In a real client this is `open(authorizationUrl)` — we follow the redirect + // chain headlessly because the demo AS auto-signs-in and auto-approves. + const res = await fetch(next, { redirect: 'manual', headers: cookie ? { cookie } : {} }); + for (const sc of res.headers.getSetCookie()) { + const pair = sc.split(';', 1)[0] ?? ''; + const eq = pair.indexOf('='); + if (eq > 0) jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()); + } + const location = res.headers.get('location'); + if (!location || res.status < 300 || res.status >= 400) { + const body = await res.text().catch(() => ''); + throw new Error(`expected a redirect at hop ${hop} (${next}); got ${res.status}\n${body.slice(0, 400)}`); + } + const resolved = new globalThis.URL(location, next); + // In a real deployment, the browser would render the consent page here and + // the user would click Approve; the demo AS's `autoConsent` flag simulates + // that approval, so the chain ends in a 302 straight to `redirect_uri`. + if (resolved.href.startsWith(CALLBACK_URL)) { + const code = resolved.searchParams.get('code'); + const error = resolved.searchParams.get('error'); + if (error) throw new Error(`AS returned error on callback: ${error} ${resolved.searchParams.get('error_description') ?? ''}`); + if (!code) throw new Error(`callback redirect missing ?code: ${resolved.href}`); + return resolved.searchParams; + } + next = resolved.href; + } + throw new Error('authorization redirect chain did not terminate at the callback within 10 hops'); +} + +const { url, era } = parseExampleArgs(); + +const client = new Client( + { name: 'oauth-headless-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); + +// ---- 1. Kick off the SDK auth driver -------------------------------------- +// The SDK builds the authorization URL and hands it to +// `redirectToAuthorization` — in `simpleOAuthClient.ts` that opens a browser; +// here we just capture it. +let capturedAuthorizationUrl: URL | undefined; +const clientMetadata: OAuthClientMetadata = { + client_name: 'Headless OAuth MCP Client (CI)', + redirect_uris: [CALLBACK_URL], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + application_type: 'native', + token_endpoint_auth_method: 'client_secret_post' +}; +const provider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, authUrl => { + capturedAuthorizationUrl = authUrl; +}); + +const firstTransport = new StreamableHTTPClientTransport(new globalThis.URL(url), { authProvider: provider }); +let challenged = false; +try { + await client.connect(firstTransport); +} catch (error) { + // Under `--legacy` the transport surfaces `UnauthorizedError` directly; + // under `mode: 'auto'` the version-negotiation probe is what got 401'd + // and wraps it in an EraNegotiationFailed `SdkError` whose `data.cause` + // is the original `UnauthorizedError`. Either way the auth driver has + // already run by the time we land here — DCR done, auth URL captured. + const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause; + if (!(root instanceof UnauthorizedError)) throw error; + challenged = true; +} +check.ok(challenged, 'first connect must 401 and throw UnauthorizedError'); +check.ok(capturedAuthorizationUrl, 'SDK auth driver should have produced an authorization URL'); +check.ok(provider.clientInformation()?.client_id, 'dynamic client registration should have run'); + +// ---- 2. Follow the authorization URL headlessly --------------------------- +// (the browser-and-user stand-in; see `followAuthorizationRedirects`). +const callbackParams = await followAuthorizationRedirects(capturedAuthorizationUrl!); + +// ---- 3. Exchange the code for tokens -------------------------------------- +// In the browser flow the local callback server hands the redirect query to +// `transport.finishAuth`; we read it off the final `Location` header instead. +// The SDK reads `code` + `iss` (RFC 9207) from the params, validates `iss` +// against the recorded issuer, then POSTs `grant_type=authorization_code` +// (+ PKCE `code_verifier`) to the AS `/token` endpoint and saves the tokens +// on `provider`. +await firstTransport.finishAuth(callbackParams); +const tokens = provider.tokens(); +check.ok(tokens?.access_token, 'token exchange should have yielded an access_token'); +check.equal(tokens?.token_type, 'Bearer'); + +// ---- 4. Reconnect with the now-populated provider ------------------------- +// A fresh transport reads the saved Bearer token from `provider` and the +// protected `/mcp` endpoint lets us through. +const transport = new StreamableHTTPClientTransport(new globalThis.URL(url), { authProvider: provider }); +await client.connect(transport); + +const result = await client.callTool({ name: 'whoami', arguments: {} }); +const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''; +const seen = JSON.parse(text) as { clientId?: string; scopes?: string[] }; +// `ctx.authInfo` round-trips: the clientId the AS minted at DCR time is the +// one the Resource Server's verifier sees on the Bearer token. +check.equal(seen.clientId, provider.clientInformation()?.client_id, 'ctx.authInfo.clientId round-trips the DCR client_id'); +check.ok(seen.scopes?.includes('openid'), 'ctx.authInfo.scopes carries a granted scope'); + +await client.close(); diff --git a/examples/client/src/dualModeAuth.ts b/examples/oauth/dualModeAuth.ts similarity index 99% rename from examples/client/src/dualModeAuth.ts rename to examples/oauth/dualModeAuth.ts index 4dd1eaded4..ac4dcc1e82 100644 --- a/examples/client/src/dualModeAuth.ts +++ b/examples/oauth/dualModeAuth.ts @@ -79,7 +79,7 @@ async function connectAndList(transport: StreamableHTTPClientTransport): Promise // --- Driver ---------------------------------------------------------------- async function main() { - const serverUrl = new URL(process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp'); + const serverUrl = new URL(process.env.MCP_SERVER_URL || 'http://127.0.0.1:3000/mcp'); const mode = process.argv[2] || 'host'; let transport: StreamableHTTPClientTransport; diff --git a/examples/oauth/package.json b/examples/oauth/package.json new file mode 100644 index 0000000000..3cae607b54 --- /dev/null +++ b/examples/oauth/package.json @@ -0,0 +1,35 @@ +{ + "name": "@mcp-examples/oauth", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts", + "client:browser": "tsx simpleOAuthClient.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "cors": "catalog:runtimeServerOnly", + "open": "^11.0.0", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "@types/cors": "catalog:devTools", + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "dual", + "path": "/mcp", + "env": { + "OAUTH_DEMO_AUTO_CONSENT": "1" + }, + "//": "client.ts drives the full authorization-code flow headlessly because OAUTH_DEMO_AUTO_CONSENT=1 makes the demo AS auto-sign-in + auto-approve, collapsing the browser dance into a 302 chain. simpleOAuthClient.ts is the manual real-browser flow — run via `pnpm client:browser`." + } +} diff --git a/examples/oauth/server.ts b/examples/oauth/server.ts new file mode 100644 index 0000000000..f83d8d8343 --- /dev/null +++ b/examples/oauth/server.ts @@ -0,0 +1,89 @@ +/** + * In-repo OAuth-protected MCP server for the **authorization-code** flow — the + * demo Resource Server that {@link ./client.ts} (headless, CI) and + * {@link ./simpleOAuthClient.ts} (manual, real browser) authenticate against. + * + * One process, two listeners on adjacent ports: + * + * - `:PORT+1` — the demo **Authorization Server** (`setupAuthServer` from + * `@mcp-examples/shared`, backed by better-auth's OIDC plugin). It + * implements the `authorization_code` grant only and auto-signs-in a fixed + * demo user. With `OAUTH_DEMO_AUTO_CONSENT=1` it also **auto-consents** — + * the `/authorize` endpoint skips the consent UI and 302s straight back to + * `redirect_uri?code=...`, so the whole browser dance becomes a chain of + * redirects a headless client can follow. + * - `:PORT` — the MCP **Resource Server**: `createMcpHandler` behind + * `requireBearerAuth({ verifier: demoTokenVerifier })`, advertising the AS + * via `createProtectedResourceMetadataRouter` (RFC 9728) so the client's + * discovery from a `401` `WWW-Authenticate` challenge works. + * + * DEMO ONLY — NOT FOR PRODUCTION. The demo AS auto-approves a fixed user; CORS + * allows every origin; tokens are validated in-process against the same demo + * AS instance. + * + * HTTP-only (Bearer auth has no stdio equivalent), so the canonical + * `if (transport === 'stdio')` branch does not apply. + */ +import { parseExampleArgs } from '@mcp-examples/shared'; +import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@mcp-examples/shared/auth'; +import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import type { McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import cors from 'cors'; +import * as z from 'zod/v4'; + +function buildServer(ctx: McpRequestContext): McpServer { + const server = new McpServer({ name: 'oauth-protected-example', version: '1.0.0' }); + server.registerTool( + 'whoami', + { description: 'Returns the authenticated subject and granted scopes.', inputSchema: z.object({}) }, + async () => ({ + content: [{ type: 'text', text: JSON.stringify({ clientId: ctx.authInfo?.clientId, scopes: ctx.authInfo?.scopes }) }] + }) + ); + return server; +} + +const { port } = parseExampleArgs(); +const AUTH_PORT = process.env.MCP_AUTH_PORT ? Number.parseInt(process.env.MCP_AUTH_PORT, 10) : port + 1; +// 127.0.0.1 (not `localhost`) so the PRM `resource` value matches the URL the +// runner passes the client byte-for-byte — the SDK auth driver enforces that. +const mcpServerUrl = new URL(`http://127.0.0.1:${port}/mcp`); +const authServerUrl = new URL(`http://127.0.0.1:${AUTH_PORT}`); + +// ---- Authorization Server (better-auth OIDC; authorization_code only) ---- +// `autoConsent` is the demo-only switch that turns the consent screen into an +// immediate 302 — set by the runner so `./client.ts` can run without a browser. +setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true, autoConsent: process.env.OAUTH_DEMO_AUTO_CONSENT === '1' }); + +// ---- Resource Server (MCP) ---- +const handler = createMcpHandler(buildServer); + +const app = createMcpExpressApp(); +// DEMO ONLY — restrict `origin` in production. `exposedHeaders` lists the +// response headers a browser-based MCP client must be able to read. +app.use( + cors({ + origin: '*', + exposedHeaders: ['Mcp-Session-Id', 'WWW-Authenticate', 'Last-Event-Id', 'Mcp-Protocol-Version'] + }) +); +// RFC 9728 Protected Resource Metadata at /.well-known/oauth-protected-resource/mcp +// — the client discovers the AS from the 401 challenge → this route → AS metadata. +app.use(createProtectedResourceMetadataRouter('/mcp')); + +const auth = requireBearerAuth({ + verifier: demoTokenVerifier, + requiredScopes: [], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) +}); +// `requireBearerAuth` sets `req.auth`; `toNodeHandler` reads it and passes it +// to the factory as `ctx.authInfo`. +const node = toNodeHandler(handler); +app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); + +app.listen(port, () => { + console.error(`OAuth-protected MCP server listening on ${mcpServerUrl.href}`); + console.error(` Protected Resource Metadata: http://127.0.0.1:${port}/.well-known/oauth-protected-resource/mcp`); +}); diff --git a/examples/client/src/simpleOAuthClient.ts b/examples/oauth/simpleOAuthClient.ts similarity index 92% rename from examples/client/src/simpleOAuthClient.ts rename to examples/oauth/simpleOAuthClient.ts index 1187f8ec1a..a1a37f3382 100644 --- a/examples/client/src/simpleOAuthClient.ts +++ b/examples/oauth/simpleOAuthClient.ts @@ -11,10 +11,15 @@ import open from 'open'; import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; // Configuration -const DEFAULT_SERVER_URL = 'http://localhost:3000/mcp'; +const DEFAULT_SERVER_URL = 'http://127.0.0.1:3000/mcp'; const CALLBACK_PORT = 8090; // Use different port than auth server (3001) const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`; +/** Minimal HTML escaper for any user/query-derived value interpolated into an HTML response. */ +function escHtml(s: string): string { + return s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); +} + /** * Interactive MCP client with OAuth authentication * Demonstrates the complete OAuth flow with browser-based authorization @@ -72,8 +77,8 @@ class InteractiveOAuthClient { /** * Starts a temporary HTTP server to receive the OAuth callback */ - private async waitForOAuthCallback(): Promise { - return new Promise((resolve, reject) => { + private async waitForOAuthCallback(): Promise { + return new Promise((resolve, reject) => { const server = createServer((req, res) => { // Ignore favicon requests if (req.url === '/favicon.ico') { @@ -100,7 +105,8 @@ class InteractiveOAuthClient { `); - resolve(code); + // Hand back the whole query — finishAuth() reads `code` + `iss` (RFC 9207) itself. + resolve(parsedUrl.searchParams); setTimeout(() => server.close(), 3000); } else if (error) { console.log(`❌ Authorization error: ${error}`); @@ -109,7 +115,7 @@ class InteractiveOAuthClient {

Authorization Failed

-

Error: ${error}

+

Error: ${escHtml(error)}

`); @@ -143,10 +149,11 @@ class InteractiveOAuthClient { } catch (error) { if (error instanceof UnauthorizedError) { console.log('🔐 OAuth required - waiting for authorization...'); - const callbackPromise = this.waitForOAuthCallback(); - const authCode = await callbackPromise; - await transport.finishAuth(authCode); - console.log('🔐 Authorization code received:', authCode); + const callbackParams = await this.waitForOAuthCallback(); + // Pass the whole callback query — the SDK extracts `code` and validates + // `iss` against the recorded issuer (RFC 9207) before exchanging the code. + await transport.finishAuth(callbackParams); + console.log('🔐 Authorization code received:', callbackParams.get('code')); console.log('🔌 Reconnecting with authenticated transport...'); await this.attemptConnection(oauthProvider); } else { @@ -167,6 +174,7 @@ class InteractiveOAuthClient { redirect_uris: [CALLBACK_URL], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], + application_type: 'native', token_endpoint_auth_method: 'client_secret_post' }; diff --git a/examples/client/src/simpleOAuthClientProvider.ts b/examples/oauth/simpleOAuthClientProvider.ts similarity index 55% rename from examples/client/src/simpleOAuthClientProvider.ts rename to examples/oauth/simpleOAuthClientProvider.ts index 1ef08279fc..5efbdaefde 100644 --- a/examples/client/src/simpleOAuthClientProvider.ts +++ b/examples/oauth/simpleOAuthClientProvider.ts @@ -1,14 +1,28 @@ -import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthClientProvider, OAuthTokens } from '@modelcontextprotocol/client'; +import type { + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthClientProvider, + OAuthDiscoveryState, + OAuthTokens +} from '@modelcontextprotocol/client'; import { validateClientMetadataUrl } from '@modelcontextprotocol/client'; /** - * In-memory OAuth client provider for demonstration purposes - * In production, you should persist tokens securely + * In-memory OAuth client provider for demonstration purposes. + * In production, you should persist tokens and client credentials securely. + * + * Tokens and client credentials are stored as single-slot blobs. The SDK stamps an + * `issuer` field onto every value it saves; round-tripping the blob unchanged means + * a credential issued by one authorization server is never reused at another (the + * SDK reads the stamp back as a key-not-found and re-registers / re-authorizes). + * To hold credentials for several authorization servers at once, key your storage + * on the `ctx.issuer` argument instead. */ export class InMemoryOAuthClientProvider implements OAuthClientProvider { private _clientInformation?: OAuthClientInformationMixed; private _tokens?: OAuthTokens; private _codeVerifier?: string; + private _discoveryState?: OAuthDiscoveryState; constructor( private readonly _redirectUrl: string | URL, @@ -66,4 +80,19 @@ export class InMemoryOAuthClientProvider implements OAuthClientProvider { } return this._codeVerifier; } + + saveDiscoveryState(state: OAuthDiscoveryState): void { + this._discoveryState = state; + } + + discoveryState(): OAuthDiscoveryState | undefined { + return this._discoveryState; + } + + invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void { + if (scope === 'all' || scope === 'client') this._clientInformation = undefined; + if (scope === 'all' || scope === 'tokens') this._tokens = undefined; + if (scope === 'all' || scope === 'verifier') this._codeVerifier = undefined; + if (scope === 'all' || scope === 'discovery') this._discoveryState = undefined; + } } diff --git a/examples/client/src/simpleTokenProvider.ts b/examples/oauth/simpleTokenProvider.ts similarity index 95% rename from examples/client/src/simpleTokenProvider.ts rename to examples/oauth/simpleTokenProvider.ts index ce68fde5a1..f8996760a6 100644 --- a/examples/client/src/simpleTokenProvider.ts +++ b/examples/oauth/simpleTokenProvider.ts @@ -11,14 +11,14 @@ * providers which implement both `token()` and `onUnauthorized()`. * * Environment variables: - * MCP_SERVER_URL - Server URL (default: http://localhost:3000/mcp) + * MCP_SERVER_URL - Server URL (default: http://127.0.0.1:3000/mcp) * MCP_TOKEN - Bearer token to use for authentication (required) */ import type { AuthProvider } from '@modelcontextprotocol/client'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp'; +const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://127.0.0.1:3000/mcp'; async function main() { const token = process.env.MCP_TOKEN; diff --git a/examples/server/package.json b/examples/package.json similarity index 60% rename from examples/server/package.json rename to examples/package.json index fcff95d9a9..105f7669e5 100644 --- a/examples/server/package.json +++ b/examples/package.json @@ -1,8 +1,8 @@ { - "name": "@modelcontextprotocol/examples-server", + "name": "@modelcontextprotocol/examples", "private": true, "version": "2.0.0-alpha.0", - "description": "Model Context Protocol implementation for TypeScript", + "description": "Runnable MCP TypeScript SDK examples — one story per directory, each a self-verifying client/server pair", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", @@ -15,44 +15,35 @@ "engines": { "node": ">=20" }, - "keywords": [ - "modelcontextprotocol", - "mcp" - ], "scripts": { "typecheck": "tsgo -p tsconfig.json --noEmit", - "build": "tsdown", - "build:watch": "tsdown --watch", - "prepack": "pnpm run build:esm && pnpm run build:cjs", - "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", - "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", - "check": "pnpm run typecheck && pnpm run lint", - "start": "pnpm run server", - "server": "tsx watch --clear-screen=false scripts/cli.ts server", - "client": "tsx scripts/cli.ts client" + "lint": "eslint . && prettier --ignore-path ../.prettierignore --check .", + "lint:fix": "eslint . --fix && prettier --ignore-path ../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint" }, "dependencies": { "@hono/node-server": "catalog:runtimeServerOnly", - "@modelcontextprotocol/examples-shared": "workspace:^", + "@modelcontextprotocol/client": "workspace:^", + "@mcp-examples/shared": "workspace:^", "@modelcontextprotocol/express": "workspace:^", "@modelcontextprotocol/hono": "workspace:^", "@modelcontextprotocol/node": "workspace:^", "@modelcontextprotocol/server": "workspace:^", "@valibot/to-json-schema": "catalog:devTools", + "ajv": "catalog:runtimeShared", "arktype": "catalog:devTools", - "better-auth": "^1.4.17", "cors": "catalog:runtimeServerOnly", "express": "catalog:runtimeServerOnly", "hono": "catalog:runtimeServerOnly", + "open": "^11.0.0", "valibot": "catalog:devTools", "zod": "catalog:runtimeShared" }, "devDependencies": { "@modelcontextprotocol/eslint-config": "workspace:^", "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", "@types/cors": "catalog:devTools", "@types/express": "catalog:devTools", - "tsdown": "catalog:devTools" + "tsx": "catalog:devTools" } } diff --git a/examples/parallel-calls/README.md b/examples/parallel-calls/README.md new file mode 100644 index 0000000000..aed18429c1 --- /dev/null +++ b/examples/parallel-calls/README.md @@ -0,0 +1,5 @@ +# parallel-calls + +Multiple clients connecting to one endpoint in parallel, and one client making parallel `callTool()` calls — with per-call logging notifications attributed back to their caller. + +Over HTTP every client connects to the one running endpoint; over stdio each client spawns its own server process (so the "one client / parallel calls" leg is the per-call attribution test on either transport). diff --git a/examples/parallel-calls/client.ts b/examples/parallel-calls/client.ts new file mode 100644 index 0000000000..41b1b6fe75 --- /dev/null +++ b/examples/parallel-calls/client.ts @@ -0,0 +1,56 @@ +/** + * Two clients in parallel, each calling the notification-emitting tool, and + * one client making two parallel tool calls — asserts every result returns + * and that notifications were attributed back to the right caller. + * + * Over HTTP every client connects to the one running endpoint; over stdio + * each `makeClient` spawns its own server process (so the + * "multiple clients" leg is per-process, while the "one client / parallel + * calls" leg exercises one server's per-call attribution either way). + */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +async function makeClient(): Promise<{ client: Client; notifications: string[] }> { + const { transport, url, era } = parseExampleArgs(); + + const client = new Client( + { name: 'parallel-calls-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } + ); + + await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + + const notifications: string[] = []; + client.setNotificationHandler('notifications/message', n => { + notifications.push(String(n.params.data)); + }); + return { client, notifications }; +} + +// --- multiple clients, one call each --- +const [a, b] = await Promise.all([makeClient(), makeClient()]); +const [ra, rb] = await Promise.all([ + a.client.callTool({ name: 'start-notification-stream', arguments: { caller: 'A', count: 3 } }), + b.client.callTool({ name: 'start-notification-stream', arguments: { caller: 'B', count: 3 } }) +]); +check.match(ra.content?.[0]?.type === 'text' ? ra.content[0].text : '', /\[A\] done/); +check.match(rb.content?.[0]?.type === 'text' ? rb.content[0].text : '', /\[B\] done/); +check.ok(a.notifications.every(m => m.includes('[A]'))); +check.ok(b.notifications.every(m => m.includes('[B]'))); +check.ok(a.notifications.length >= 3 && b.notifications.length >= 3); +await a.client.close(); +await b.client.close(); + +// --- one client, parallel tool calls --- +const c = await makeClient(); +const results = await Promise.all([ + c.client.callTool({ name: 'start-notification-stream', arguments: { caller: 'C1', count: 2 } }), + c.client.callTool({ name: 'start-notification-stream', arguments: { caller: 'C2', count: 2 } }) +]); +check.equal(results.length, 2); +check.ok(c.notifications.some(m => m.includes('[C1]')) && c.notifications.some(m => m.includes('[C2]'))); +await c.client.close(); diff --git a/examples/parallel-calls/package.json b/examples/parallel-calls/package.json new file mode 100644 index 0000000000..7bf6831f81 --- /dev/null +++ b/examples/parallel-calls/package.json @@ -0,0 +1,19 @@ +{ + "name": "@mcp-examples/parallel-calls", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + } +} diff --git a/examples/parallel-calls/server.ts b/examples/parallel-calls/server.ts new file mode 100644 index 0000000000..c2b390b32b --- /dev/null +++ b/examples/parallel-calls/server.ts @@ -0,0 +1,49 @@ +/** + * One notification-emitting tool that the parallel-calls client drives with + * multiple concurrent clients (HTTP) or one client / multiple concurrent + * calls (both transports), asserting in-flight notifications are attributed + * back to the right caller. One binary, either transport. + */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'parallel-calls-example', version: '1.0.0' }, { capabilities: { logging: {} } }); + server.registerTool( + 'start-notification-stream', + { + description: 'Sends a few periodic logging notifications tagged with the caller id', + inputSchema: z.object({ caller: z.string(), count: z.number().int().min(1).max(20).default(3) }) + }, + async ({ caller, count }, ctx) => { + for (let i = 1; i <= count; i++) { + // Send as a request-tied notification so it rides the same SSE + // stream as the eventual result. + await ctx.mcpReq.notify({ + method: 'notifications/message', + params: { level: 'info', data: `[${caller}] tick ${i}/${count}` } + }); + await new Promise(r => setTimeout(r, 20)); + } + return { content: [{ type: 'text', text: `[${caller}] done (${count})` }] }; + } + ); + return server; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/prompts/README.md b/examples/prompts/README.md new file mode 100644 index 0000000000..fe42008587 --- /dev/null +++ b/examples/prompts/README.md @@ -0,0 +1,7 @@ +# prompts + +Register prompts with `McpServer.registerPrompt`; wrap argument schemas with `completable(...)` for autocompletion. The client lists prompts, completes the `language` argument, and renders one with `getPrompt()`. + +```bash +pnpm tsx examples/prompts/client.ts +``` diff --git a/examples/prompts/client.ts b/examples/prompts/client.ts new file mode 100644 index 0000000000..90ed76847e --- /dev/null +++ b/examples/prompts/client.ts @@ -0,0 +1,33 @@ +/** + * Drives the prompts example: list, complete an argument, get a prompt. + */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const { transport, url, era } = parseExampleArgs(); + +const client = new Client( + { name: 'prompts-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); + +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + +const list = await client.listPrompts(); +check.ok(list.prompts.some(p => p.name === 'review-code')); + +const completion = await client.complete({ + ref: { type: 'ref/prompt', name: 'review-code' }, + argument: { name: 'language', value: 'ty' } +}); +check.ok(completion.completion.values.includes('typescript')); + +const got = await client.getPrompt({ name: 'review-code', arguments: { language: 'rust', code: 'fn main() {}' } }); +check.equal(got.messages.length, 1); +const text = got.messages[0]?.content.type === 'text' ? got.messages[0].content.text : ''; +check.match(text, /Review this rust code/); + +await client.close(); diff --git a/examples/prompts/package.json b/examples/prompts/package.json new file mode 100644 index 0000000000..4f46b52436 --- /dev/null +++ b/examples/prompts/package.json @@ -0,0 +1,19 @@ +{ + "name": "@mcp-examples/prompts", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + } +} diff --git a/examples/prompts/server.ts b/examples/prompts/server.ts new file mode 100644 index 0000000000..8732d3d748 --- /dev/null +++ b/examples/prompts/server.ts @@ -0,0 +1,54 @@ +/** + * Prompts primitive + completion. + * + * Register prompts with `McpServer.registerPrompt`; wrap an arg schema with + * `completable(...)` so the client's `complete()` call returns suggestions. + * One binary, either transport. + */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { completable, createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +const LANGUAGES = ['python', 'typescript', 'rust', 'go']; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'prompts-example', version: '1.0.0' }); + + server.registerPrompt( + 'review-code', + { + title: 'Code review', + description: 'Review code for quality and idioms', + argsSchema: z.object({ + language: completable(z.string().describe('Programming language'), value => LANGUAGES.filter(l => l.startsWith(value))), + code: z.string().describe('The code to review') + }) + }, + async ({ language, code }) => ({ + messages: [ + { + role: 'user', + content: { type: 'text', text: `Review this ${language} code for quality and idioms:\n\n${code}` } + } + ] + }) + ); + + return server; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/repl/README.md b/examples/repl/README.md new file mode 100644 index 0000000000..beb492a733 --- /dev/null +++ b/examples/repl/README.md @@ -0,0 +1,13 @@ +# repl (excluded) + +The interactive playground. A fully-featured **sessionful** HTTP server (tools with input/output schemas + annotations, prompts with completion, direct + templated resources, `notifications/message` logging, `resources/list_changed`, in-memory `eventStore` for resumability) +paired with a readline REPL client that can drive every primitive by hand — `list-tools`, `call-tool`, `list-prompts`, `get-prompt`, `list-resources`, `read-resource`, form elicitation, resumable notification streams (`reconnect`, `run-notifications-tool-with-resumability`). + +Excluded from the runner (`package.json#example.excluded`); run manually: + +```sh +pnpm run server # terminal 1 — listens on http://localhost:3000/mcp +pnpm run client # terminal 2 — readline REPL +``` + +Try `multi-greet Ada`, `collect-info contact`, `call-tool add-resource {"name":"n1","text":"hello"}` then `list-resources`, or `start-notifications 500 5`. diff --git a/examples/client/src/simpleStreamableHttp.ts b/examples/repl/client.ts similarity index 97% rename from examples/client/src/simpleStreamableHttp.ts rename to examples/repl/client.ts index 6c8be12610..ccb1ca2c0c 100644 --- a/examples/client/src/simpleStreamableHttp.ts +++ b/examples/repl/client.ts @@ -1,5 +1,17 @@ +/** + * Interactive readline REPL for driving an MCP server by hand. + * + * The canonical top-level-await connect→assert→close shape does not apply: + * this is an interactive command loop (stdin is the readline + * prompt, so there is no stdio-transport arm) and the `connect`/`disconnect`/ + * `reconnect`/`terminate-session` commands deliberately tear the transport up + * and down repeatedly. The explicit `new Client(...)` / + * `new StreamableHTTPClientTransport(...)` / `client.connect(...)` calls live + * inline in `connect()` below so the SDK surface is still visible. + */ import { createInterface } from 'node:readline'; +import { parseExampleArgs } from '@mcp-examples/shared'; import type { GetPromptRequest, ListPromptsRequest, @@ -23,7 +35,7 @@ let notificationCount = 0; // Global client and transport for interactive commands let client: Client | null = null; let transport: StreamableHTTPClientTransport | null = null; -let serverUrl = 'http://localhost:3000/mcp'; +let serverUrl = parseExampleArgs().url; let notificationsToolLastEventId: string | undefined; let sessionId: string | undefined; diff --git a/examples/repl/package.json b/examples/repl/package.json new file mode 100644 index 0000000000..01c68d4d19 --- /dev/null +++ b/examples/repl/package.json @@ -0,0 +1,26 @@ +{ + "name": "@mcp-examples/repl", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "ajv": "catalog:runtimeShared", + "express": "catalog:runtimeServerOnly", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "@types/express": "catalog:devTools", + "tsx": "catalog:devTools" + }, + "example": { + "excluded": "interactive REPL — run manually" + } +} diff --git a/examples/repl/server.ts b/examples/repl/server.ts new file mode 100644 index 0000000000..33f70bf631 --- /dev/null +++ b/examples/repl/server.ts @@ -0,0 +1,289 @@ +/** + * Fully-featured **sessionful** HTTP playground server for the interactive + * REPL client. + * + * Exposes every primitive the REPL client (`./client.ts`) can drive: tools + * (typed input/output schemas + annotations + form elicitation + + * `ResourceLink`s), prompts (with `completable()` argument completion), + * resources (direct + `ResourceTemplate`), `notifications/message` logging, + * and `notifications/resources/list_changed`. + * + * HTTP-only and sessionful by design: hosted on + * `NodeStreamableHTTPServerTransport` with an in-memory `eventStore` so the + * REPL client's `reconnect`, `terminate-session`, and + * `run-notifications-tool-with-resumability` commands actually replay missed + * events on reconnect with `Last-Event-ID`. The canonical + * `serveStdio` / `createMcpHandler` arms cannot express that, and the REPL + * client uses stdin for the readline command loop. + * + * Pair with `pnpm run client` in a second terminal. + */ +import { randomUUID } from 'node:crypto'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { InMemoryEventStore } from '@mcp-examples/shared/auth'; +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { CallToolResult, PrimitiveSchemaDefinition, ReadResourceResult, ResourceLink } from '@modelcontextprotocol/server'; +import { completable, isInitializeRequest, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import type { Request, Response } from 'express'; +import * as z from 'zod/v4'; + +/** Dynamic resources added via the `add-resource` tool (shared across sessions). */ +const dynamicResources = new Map(); + +function buildServer(): McpServer { + const server = new McpServer( + { + name: 'repl-playground-server', + version: '1.0.0', + icons: [{ src: 'https://modelcontextprotocol.io/favicon.svg', sizes: ['any'], mimeType: 'image/svg+xml' }], + websiteUrl: 'https://github.com/modelcontextprotocol/typescript-sdk' + }, + { capabilities: { logging: {}, resources: { listChanged: true } } } + ); + + // --- Tools ------------------------------------------------------------- + + // Typed input + inferred structured output + read-only annotation. + server.registerTool( + 'greet', + { + title: 'Greeting Tool', + description: 'Returns a greeting for the named subject', + inputSchema: z.object({ name: z.string().describe('Name to greet') }), + outputSchema: z.object({ greeting: z.string() }), + annotations: { readOnlyHint: true, idempotentHint: true } + }, + async ({ name }) => { + const structuredContent = { greeting: `Hello, ${name}!` }; + return { content: [{ type: 'text', text: structuredContent.greeting }], structuredContent }; + } + ); + + // Sends `notifications/message` log lines while it runs (drive with `multi-greet`). + server.registerTool( + 'multi-greet', + { + description: 'Sends several greetings with a delay between each, emitting log notifications as it goes', + inputSchema: z.object({ name: z.string().describe('Name to greet') }), + annotations: { title: 'Multiple Greeting Tool', readOnlyHint: true, openWorldHint: false } + }, + async ({ name }, ctx): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + await ctx.mcpReq.log('debug', `Starting multi-greet for ${name}`); + await sleep(500); + await ctx.mcpReq.log('info', `Sending first greeting to ${name}`); + await sleep(500); + await ctx.mcpReq.log('info', `Sending second greeting to ${name}`); + return { content: [{ type: 'text', text: `Good morning, ${name}!` }] }; + } + ); + + // Form-mode elicitation (drive with the REPL's `collect-info` command). + server.registerTool( + 'collect-user-info', + { + description: 'Collects user information through form elicitation', + inputSchema: z.object({ + infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect') + }) + }, + async ({ infoType }, ctx): Promise => { + const schemas: Record< + string, + { message: string; schema: { type: 'object'; properties: Record; required?: string[] } } + > = { + contact: { + message: 'Please provide your contact information', + schema: { + type: 'object', + properties: { + name: { type: 'string', title: 'Full Name' }, + email: { type: 'string', title: 'Email Address', format: 'email' } + }, + required: ['name', 'email'] + } + }, + preferences: { + message: 'Please set your preferences', + schema: { + type: 'object', + properties: { + theme: { type: 'string', title: 'Theme', enum: ['light', 'dark', 'auto'] }, + notifications: { type: 'boolean', title: 'Enable Notifications', default: true } + }, + required: ['theme'] + } + }, + feedback: { + message: 'Please provide your feedback', + schema: { + type: 'object', + properties: { + rating: { type: 'integer', title: 'Rating', minimum: 1, maximum: 5 }, + comments: { type: 'string', title: 'Comments', maxLength: 500 } + }, + required: ['rating'] + } + } + }; + const picked = schemas[infoType]!; + const result = await ctx.mcpReq.send({ + method: 'elicitation/create', + params: { mode: 'form', message: picked.message, requestedSchema: picked.schema } + }); + if (result.action === 'accept') { + return { content: [{ type: 'text', text: `Collected ${infoType}: ${JSON.stringify(result.content, null, 2)}` }] }; + } + return { + content: [{ type: 'text', text: `User ${result.action === 'decline' ? 'declined' : 'cancelled'} the ${infoType} request.` }] + }; + } + ); + + // Periodic notifications for testing resumability (`start-notifications` in the REPL). + server.registerTool( + 'start-notification-stream', + { + description: 'Sends periodic log notifications for testing resumability', + inputSchema: z.object({ + interval: z.number().describe('Interval in ms between notifications').default(1000), + count: z.number().describe('Number of notifications to send').default(5) + }) + }, + async ({ interval, count }, ctx): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + for (let i = 1; i <= count; i++) { + await ctx.mcpReq.log('info', `Periodic notification #${i} at ${new Date().toISOString()}`); + await sleep(interval); + } + return { content: [{ type: 'text', text: `Sent ${count} notifications at ${interval}ms intervals` }] }; + } + ); + + // Mutates the resource set and publishes `resources/list_changed` on this + // session's standalone SSE stream. + server.registerTool( + 'add-resource', + { + description: 'Add a dynamic note resource and publish resources/list_changed', + inputSchema: z.object({ name: z.string(), text: z.string() }), + annotations: { destructiveHint: false } + }, + async ({ name, text }): Promise => { + dynamicResources.set(name, text); + server.sendResourceListChanged(); + return { content: [{ type: 'text', text: `Added note://${name}` }] }; + } + ); + + // Returns ResourceLinks (drive with `call-tool list-files`, then `read-resource `). + server.registerTool( + 'list-files', + { + title: 'List Files with ResourceLinks', + description: 'Returns a list of files as ResourceLinks without embedding their content', + inputSchema: z.object({}) + }, + async (): Promise => { + const links: ResourceLink[] = [ + { type: 'resource_link', uri: 'config://app', name: 'App config', mimeType: 'application/json' }, + ...[...dynamicResources.keys()].map( + (name): ResourceLink => ({ type: 'resource_link', uri: `note://${name}`, name, mimeType: 'text/plain' }) + ) + ]; + return { content: [{ type: 'text', text: 'Available files:' }, ...links] }; + } + ); + + // --- Prompts (with argument completion) -------------------------------- + + const LANGUAGES = ['python', 'typescript', 'rust', 'go']; + server.registerPrompt( + 'greeting-template', + { + title: 'Greeting Template', + description: 'A simple greeting prompt template', + argsSchema: z.object({ + name: z.string().describe('Name to include in greeting'), + language: completable(z.string().describe('Language'), value => LANGUAGES.filter(l => l.startsWith(value))) + }) + }, + async ({ name, language }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: `Please greet ${name} in ${language}.` } }] + }) + ); + + // --- Resources (direct + template + dynamic) --------------------------- + + server.registerResource( + 'app-config', + 'config://app', + { title: 'App config', mimeType: 'application/json', description: 'Static application config' }, + async (uri): Promise => ({ + contents: [{ uri: uri.href, mimeType: 'application/json', text: '{"feature":true}' }] + }) + ); + + server.registerResource( + 'greeting', + new ResourceTemplate('greeting://{name}', { list: undefined }), + { description: 'A greeting for the named subject' }, + async (uri, vars): Promise => ({ contents: [{ uri: uri.href, text: `Hello, ${vars.name}!` }] }) + ); + + server.registerResource( + 'note', + new ResourceTemplate('note://{name}', { + list: () => ({ + resources: [...dynamicResources.keys()].map(name => ({ uri: `note://${name}`, name, mimeType: 'text/plain' })) + }) + }), + { description: 'A dynamic note added via add-resource', mimeType: 'text/plain' }, + async (uri, vars): Promise => { + const text = dynamicResources.get(String(vars.name)) ?? '(no such note)'; + return { contents: [{ uri: uri.href, mimeType: 'text/plain', text }] }; + } + ); + + return server; +} + +const { port } = parseExampleArgs(); + +// Sessionful 2025-era hosting with an in-memory event store so the REPL +// client's resumability commands work (reconnect with `Last-Event-ID` replays +// missed `notifications/message` events). +const sessions = new Map(); +const eventStore = new InMemoryEventStore(); + +const app = createMcpExpressApp(); +app.all('/mcp', async (req: Request, res: Response) => { + const sid = req.headers['mcp-session-id'] as string | undefined; + if (sid && sessions.has(sid)) { + await sessions.get(sid)!.handleRequest(req, res, req.body); + } else if (!sid && isInitializeRequest(req.body)) { + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, // resumability — events are persisted for replay on GET reconnect + onsessioninitialized: id => { + sessions.set(id, transport); + } + }); + transport.onclose = () => transport.sessionId && sessions.delete(transport.sessionId); + await buildServer().connect(transport); + await transport.handleRequest(req, res, req.body); + } else if (sid) { + res.status(404).json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }); + } else { + res.status(400).json({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: Session ID required' }, id: null }); + } +}); + +app.listen(port, () => console.error(`[server] REPL playground listening on http://127.0.0.1:${port}/mcp`)); + +process.on('SIGINT', async () => { + for (const t of sessions.values()) await t.close(); + process.exit(0); +}); diff --git a/examples/resources/README.md b/examples/resources/README.md new file mode 100644 index 0000000000..409df6833c --- /dev/null +++ b/examples/resources/README.md @@ -0,0 +1,7 @@ +# resources + +Direct resources (a fixed URI string) and templated resources (`ResourceTemplate('greeting://{name}')`). The client lists both, reads the direct config, and reads a templated greeting. + +```bash +pnpm tsx examples/resources/client.ts +``` diff --git a/examples/resources/client.ts b/examples/resources/client.ts new file mode 100644 index 0000000000..c81fdf4bce --- /dev/null +++ b/examples/resources/client.ts @@ -0,0 +1,33 @@ +/** + * Drives the resources example: list, list templates, read direct + templated. + */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const { transport, url, era } = parseExampleArgs(); + +const client = new Client( + { name: 'resources-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); + +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + +const list = await client.listResources(); +check.ok(list.resources.some(r => r.uri === 'config://app')); + +const templates = await client.listResourceTemplates(); +check.ok(templates.resourceTemplates.some(t => t.uriTemplate === 'greeting://{name}')); + +const config = await client.readResource({ uri: 'config://app' }); +const configContent = config.contents[0]; +check.equal(configContent && 'text' in configContent ? configContent.text : '', '{"feature":true}'); + +const hello = await client.readResource({ uri: 'greeting://world' }); +const helloContent = hello.contents[0]; +check.equal(helloContent && 'text' in helloContent ? helloContent.text : '', 'Hello, world!'); + +await client.close(); diff --git a/examples/resources/package.json b/examples/resources/package.json new file mode 100644 index 0000000000..5fb5df3b83 --- /dev/null +++ b/examples/resources/package.json @@ -0,0 +1,18 @@ +{ + "name": "@mcp-examples/resources", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*" + }, + "devDependencies": { + "tsx": "catalog:devTools" + } +} diff --git a/examples/resources/server.ts b/examples/resources/server.ts new file mode 100644 index 0000000000..069be5769a --- /dev/null +++ b/examples/resources/server.ts @@ -0,0 +1,47 @@ +/** + * Resources primitive — direct + templated. + * + * `McpServer.registerResource` accepts either a fixed URI string (direct + * resource) or a `ResourceTemplate` (URI template with substitution). One + * binary, either transport — selected from argv below. + */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'resources-example', version: '1.0.0' }); + + // A direct resource at a fixed URI. + server.registerResource( + 'app-config', + 'config://app', + { mimeType: 'application/json', description: 'Static application config' }, + async uri => ({ contents: [{ uri: uri.href, mimeType: 'application/json', text: '{"feature":true}' }] }) + ); + + // A templated resource: `greeting://{name}`. + server.registerResource( + 'greeting', + new ResourceTemplate('greeting://{name}', { list: undefined }), + { description: 'A greeting for the named subject' }, + async (uri, vars) => ({ contents: [{ uri: uri.href, text: `Hello, ${vars.name}!` }] }) + ); + + return server; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/sampling/README.md b/examples/sampling/README.md new file mode 100644 index 0000000000..ad9ad86f6b --- /dev/null +++ b/examples/sampling/README.md @@ -0,0 +1,20 @@ +# sampling + +A tool that asks the host LLM for a completion. One factory, both protocol eras: sampling works on both eras with different APIs — push-style on 2025, `inputRequired` on 2026; the protocol carries the `sampling/createMessage` request differently but the user experience is the +same. + +| 2025-era (`--legacy`, push-style) | 2026-07-28 (multi-round-trip) | +| ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `await ctx.mcpReq.requestSampling({ messages, maxTokens })` — the server pushes a `sampling/createMessage` request and awaits the answer in-line | `return inputRequired({ inputRequests: { summary: inputRequired.createMessage({ messages, maxTokens }) } })` — the client fulfils the embedded request and retries with the response attached | + +The client registers **one** `sampling/createMessage` handler; on the 2026-07-28 leg the auto-fulfilment driver dispatches the embedded request to that same handler. + +> Push-style sampling is **deprecated** as of protocol revision 2026-07-28 (SEP-2577) but remains functional during the deprecation window. + +Push-style sampling is exercised on **stdio/legacy** (`createMcpHandler`'s stateless-legacy posture has no return path for the client's response POST — see `../legacy-routing/` for the sessionful composition); the http/legacy leg only verifies the initialize handshake. +2026-07-28 `inputRequired.createMessage` runs on both transports. + +```bash +pnpm --filter @mcp-examples/sampling client # 2026-07-28 (inputRequired) +pnpm --filter @mcp-examples/sampling client -- --legacy # 2025 (push-style) +``` diff --git a/examples/sampling/client.ts b/examples/sampling/client.ts new file mode 100644 index 0000000000..21c323732b --- /dev/null +++ b/examples/sampling/client.ts @@ -0,0 +1,52 @@ +/** + * Advertises the sampling capability, registers a `sampling/createMessage` + * handler that returns a canned summary, then calls the `summarize` tool and + * asserts the canned text round-tripped. + * + * The same handler serves both protocol eras: on the 2025-era leg + * (`--legacy`) the server pushes `sampling/createMessage` and this handler + * answers it directly; on the 2026-07-28 leg the auto-fulfilment driver + * dispatches the embedded `sampling/createMessage` from the server's + * `inputRequired` result to this same handler, then retries the tool call + * with the response attached. + */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const { transport, url, era } = parseExampleArgs(); + +const client = new Client( + { name: 'sampling-example-client', version: '1.0.0' }, + { + versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' }, + capabilities: { sampling: {} } + } +); + +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + +client.setRequestHandler('sampling/createMessage', async () => ({ + role: 'assistant', + content: { type: 'text', text: '[canned summary]' }, + model: 'stub', + stopReason: 'endTurn' +})); + +if (transport === 'http' && era === 'legacy') { + // Push-style `ctx.mcpReq.requestSampling` needs a sessionful return + // path: the client's response to `sampling/createMessage` is a separate + // POST that must reach the SAME server instance that sent the request. + // `createMcpHandler`'s default stateless-legacy posture has no such + // path — see `../legacy-routing/` for the sessionful `isLegacyRequest` + // composition. The push-style flow is exercised on stdio/legacy; this + // leg only verifies the 2025 `initialize` handshake succeeded. + check.ok(client.getServerCapabilities()?.tools); +} else { + const result = await client.callTool({ name: 'summarize', arguments: { text: 'hello world' } }); + check.equal(result.content?.[0]?.type === 'text' ? result.content[0].text : '', '[canned summary]'); +} + +await client.close(); diff --git a/examples/sampling/package.json b/examples/sampling/package.json new file mode 100644 index 0000000000..f4981410af --- /dev/null +++ b/examples/sampling/package.json @@ -0,0 +1,23 @@ +{ + "name": "@mcp-examples/sampling", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "dual", + "//": "2025-era push-style ctx.mcpReq.requestSampling is exercised on stdio/legacy (createMcpHandler's stateless-legacy posture has no return path for the client's response POST — see ../legacy-routing/ for the sessionful composition); 2026-07-28 inputRequired.createMessage runs on both transports." + } +} diff --git a/examples/sampling/server.ts b/examples/sampling/server.ts new file mode 100644 index 0000000000..cf45c3a356 --- /dev/null +++ b/examples/sampling/server.ts @@ -0,0 +1,77 @@ +/** + * Sampling — a tool that asks the host LLM for a completion. One factory, + * both protocol eras. + * + * The same tool serves both eras with different APIs: on a 2025-era + * connection (`--legacy`, the `initialize` handshake) the server uses the + * push-style server→client request flow — `ctx.mcpReq.requestSampling(...)` + * sends `sampling/createMessage` and awaits the answer in-line. On a + * 2026-07-28 connection there is no server→client request channel: the same + * tool instead **returns** `inputRequired(...)` with an embedded + * `sampling/createMessage`, and the client retries with the model's response + * attached. The protocol carries the request differently; the user + * experience is the same. + * + * One binary, either transport. Logs go to stderr only — stdio's stdout is + * the JSON-RPC stream. + */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import type { CallToolResult, InputRequiredResult, McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, inputRequired, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +function buildServer(reqCtx: McpRequestContext): McpServer { + const server = new McpServer({ name: 'sampling-example', version: '1.0.0' }); + + server.registerTool( + 'summarize', + { description: 'Summarize text using the host LLM', inputSchema: z.object({ text: z.string() }) }, + async ({ text }, ctx): Promise => { + const messages = [ + { + role: 'user' as const, + content: { type: 'text' as const, text: `Please summarize the following text concisely:\n\n${text}` } + } + ]; + if (reqCtx.era === 'legacy') { + // 2025-era: push a server→client `sampling/createMessage` request + // and await the model's answer in-line. + const response = await ctx.mcpReq.requestSampling({ messages, maxTokens: 500 }); + // `content` is a single block when no tools were passed. + const content = response.content; + const summary = !Array.isArray(content) && content.type === 'text' ? content.text : 'Unable to generate summary'; + return { content: [{ type: 'text', text: summary }] }; + } + // 2026-07-28: return inputRequired with an embedded + // `sampling/createMessage` — the client's auto-fulfilment driver + // dispatches it to the same `sampling/createMessage` handler and + // retries this call with the model's response attached. + const response = ctx.mcpReq.inputResponses?.['summary'] as { content?: { type: string; text?: string } } | undefined; + if (!response) { + return inputRequired({ + inputRequests: { summary: inputRequired.createMessage({ messages, maxTokens: 500 }) } + }); + } + const summary = response.content?.type === 'text' ? (response.content.text ?? '') : 'Unable to generate summary'; + return { content: [{ type: 'text', text: summary }] }; + } + ); + + return server; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/schema-validators/README.md b/examples/schema-validators/README.md new file mode 100644 index 0000000000..4ee9673b53 --- /dev/null +++ b/examples/schema-validators/README.md @@ -0,0 +1,7 @@ +# schema-validators + +Tool input/output schemas via Zod, ArkType and Valibot — any Standard-Schema-with-JSON library works. Also shows `outputSchema` → `structuredContent`, including an array-root `outputSchema` (SEP-2106) with the auto-injected `TextContent` fallback and the client-side `unknown` runtime-narrowing pattern. + +```bash +pnpm tsx examples/schema-validators/client.ts +``` diff --git a/examples/schema-validators/client.ts b/examples/schema-validators/client.ts new file mode 100644 index 0000000000..f2df6371ca --- /dev/null +++ b/examples/schema-validators/client.ts @@ -0,0 +1,62 @@ +/** + * Calls each greet variant and asserts every inputSchema published as a JSON + * Schema with a required `name` string; calls `get-weather` and asserts the + * structured output matches. + */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const { transport, url, era } = parseExampleArgs(); + +const client = new Client( + { name: 'schema-validators-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); + +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + +const list = await client.listTools(); +for (const name of ['greet-zod', 'greet-arktype', 'greet-valibot']) { + const tool = list.tools.find(t => t.name === name); + check.ok(tool, `${name} should be listed`); + const required = (tool!.inputSchema as { required?: string[] }).required ?? []; + check.ok(required.includes('name'), `${name} inputSchema should require 'name'`); + const result = await client.callTool({ name, arguments: { name: 'world' } }); + check.match(result.content?.[0]?.type === 'text' ? result.content[0].text : '', /Hello, world!/); +} + +// structuredContent is typed `unknown` (SEP-2106). The SDK has already +// runtime-validated it against the server's outputSchema. This client is +// written FOR the paired server above, so the shape is known and a cast is +// the honest known-server idiom (same as C# `.Deserialize()` or Go +// `json.Unmarshal`). A generic host that connects to arbitrary servers +// would not cast; it would render the JSON or narrow at runtime. +const weather = await client.callTool({ name: 'get-weather', arguments: { city: 'Tokyo' } }); +const w = weather.structuredContent as { city: string; conditions: string; celsius: number }; +check.equal(w.city, 'Tokyo'); +check.equal(w.conditions, 'sunny'); +check.equal(w.celsius, 21); + +// SEP-2106: array structuredContent. The SDK auto-injects a serialized +// JSON text block alongside it. On the legacy era the array is wrapped as +// `{result: }` (the 2025 wire shape only carries object +// structuredContent), so the natural value is at `.result`. +const forecasts = await client.callTool({ name: 'list-forecasts', arguments: { city: 'Tokyo' } }); +const text = forecasts.content?.find(c => c.type === 'text'); +check.ok(text, 'auto-injected TextContent fallback present'); +check.match(text.text, /"hour":"09:00"/); +type Forecast = { hour: string; celsius: number }; +if (era === 'legacy') { + const sc = forecasts.structuredContent as { result: Forecast[] }; + check.equal(sc.result.length, 2); + check.equal(sc.result[0]?.hour, '09:00'); +} else { + const sc = forecasts.structuredContent as Forecast[]; + check.equal(sc.length, 2); + check.equal(sc[0]?.hour, '09:00'); +} + +await client.close(); diff --git a/examples/schema-validators/package.json b/examples/schema-validators/package.json new file mode 100644 index 0000000000..cc94d3b645 --- /dev/null +++ b/examples/schema-validators/package.json @@ -0,0 +1,22 @@ +{ + "name": "@mcp-examples/schema-validators", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "@valibot/to-json-schema": "catalog:devTools", + "arktype": "catalog:devTools", + "valibot": "catalog:devTools", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + } +} diff --git a/examples/schema-validators/server.ts b/examples/schema-validators/server.ts new file mode 100644 index 0000000000..95408951ce --- /dev/null +++ b/examples/schema-validators/server.ts @@ -0,0 +1,87 @@ +/** + * Tool input/output schemas via three Standard-Schema-compatible libraries + * (Zod, ArkType, Valibot) plus an `outputSchema` that emits + * `structuredContent`. The SDK accepts any Standard-Schema-with-JSON value; + * Valibot needs the `@valibot/to-json-schema` wrapper to expose JSON Schema + * conversion. One binary, either transport. + */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import { toStandardJsonSchema } from '@valibot/to-json-schema'; +import { type } from 'arktype'; +import * as v from 'valibot'; +import * as z from 'zod/v4'; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'schema-validators-example', version: '1.0.0' }); + + server.registerTool( + 'greet-zod', + { description: 'Greet (Zod inputSchema)', inputSchema: z.object({ name: z.string() }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}! (zod)` }] }) + ); + + server.registerTool( + 'greet-arktype', + { description: 'Greet (ArkType inputSchema)', inputSchema: type({ name: 'string' }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}! (arktype)` }] }) + ); + + server.registerTool( + 'greet-valibot', + { description: 'Greet (Valibot inputSchema)', inputSchema: toStandardJsonSchema(v.object({ name: v.string() })) }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}! (valibot)` }] }) + ); + + // outputSchema → structuredContent. + server.registerTool( + 'get-weather', + { + description: 'Get (canned) weather information', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.object({ city: z.string(), conditions: z.enum(['sunny', 'cloudy', 'rainy']), celsius: z.number() }) + }, + async ({ city }) => { + const structuredContent = { city, conditions: 'sunny' as const, celsius: 21 }; + return { content: [{ type: 'text', text: JSON.stringify(structuredContent) }], structuredContent }; + } + ); + + // SEP-2106: outputSchema may have any JSON Schema root (here an array), and + // structuredContent may be any JSON value. When structuredContent is not an + // object and the handler returns no text block, the SDK injects a serialized + // JSON text block so legacy clients have something to read. + server.registerTool( + 'list-forecasts', + { + description: 'Hourly forecast (array structuredContent)', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.array(z.object({ hour: z.string(), celsius: z.number() })) + }, + async () => ({ + content: [], + structuredContent: [ + { hour: '09:00', celsius: 18 }, + { hour: '10:00', celsius: 21 } + ] + }) + ); + + return server; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/scoped-tools/README.md b/examples/scoped-tools/README.md new file mode 100644 index 0000000000..570ec8fcc5 --- /dev/null +++ b/examples/scoped-tools/README.md @@ -0,0 +1,26 @@ +# scoped-tools — per-tool scope enforced in the tool handler + +Demonstrates per-tool OAuth scope enforcement on a `createMcpHandler` +deployment: the HTTP gate does **bearer-verify + 401 only**, and each tool +handler checks `ctx.http?.authInfo?.scopes` for the scope it needs. The scope +decision lives next to the code it guards — the handler is the only place that +authoritatively knows which tool is executing — instead of in middleware that +would have to re-derive the operation from the request body. + +`server.ts` runs a minimal demo Authorization Server alongside the MCP Resource +Server. `client.ts` connects with a `files:read` token, calls `list-files` +(works), then calls `write-file` → the handler returns `{ isError: true }` with +`insufficient_scope: requires files:write`. + +The transport's automatic `403 insufficient_scope` **step-up** flow (SEP-2350 — +scope union, refresh-bypass, `maxStepUpRetries`) applies when the RS responds +`403` at the HTTP layer; that path is exercised by +`test/e2e/scenarios/client-auth.test.ts`. + +```bash +pnpm --filter @mcp-examples/scoped-tools server -- --http --port 3000 +pnpm --filter @mcp-examples/scoped-tools client -- --http http://127.0.0.1:3000/mcp +``` + +> DEMO ONLY — the bundled AS auto-approves and grants whatever scope is asked +> for. Do not deploy. diff --git a/examples/scoped-tools/client.ts b/examples/scoped-tools/client.ts new file mode 100644 index 0000000000..357b7273c5 --- /dev/null +++ b/examples/scoped-tools/client.ts @@ -0,0 +1,86 @@ +/** + * Self-verifying per-tool scope client. + * + * Drives the same OAuth machinery as `examples/oauth/client.ts` to obtain a + * `files:read` token, then exercises the server's handler-level per-tool scope + * checks: `list-files` succeeds; `write-file` returns a tool-result + * `{ isError: true }` because the token lacks `files:write`. The transport's + * automatic `403 insufficient_scope` step-up (SEP-2350) is exercised by the + * dedicated e2e scenario (`test/e2e/scenarios/client-auth.test.ts`); this + * example demonstrates the recommended server-side pattern of enforcing scope + * inside the tool handler that needs it. + * + * HTTP-only (the OAuth dance is HTTP redirects + Bearer headers), so the + * canonical stdio branch does not apply. + */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; +import type { OAuthClientMetadata } from '@modelcontextprotocol/client'; +import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client'; + +import { InMemoryOAuthClientProvider } from '../oauth/simpleOAuthClientProvider.js'; + +const CALLBACK_URL = 'http://127.0.0.1:8091/callback'; + +/** Follow the demo AS's auto-consent 302 and return the `code`. */ +async function followAuthorize(authorizationUrl: URL): Promise { + const res = await fetch(authorizationUrl, { redirect: 'manual' }); + const location = res.headers.get('location'); + if (!location || res.status !== 302) throw new Error(`expected 302 from /authorize, got ${res.status}`); + const code = new globalThis.URL(location).searchParams.get('code'); + if (!code) throw new Error(`authorize redirect missing ?code: ${location}`); + return code; +} + +const { url } = parseExampleArgs(); + +// Modern-only — authInfo plumbing through ServerContext is the feature under +// demonstration; the legacy era does not exercise it. +const client = new Client({ name: 'scoped-tools-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + +const captured: URL[] = []; +const clientMetadata: OAuthClientMetadata = { + client_name: 'Scoped-Tools Step-Up Client', + redirect_uris: [CALLBACK_URL], + application_type: 'native', + grant_types: ['authorization_code'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + scope: 'files:read' +}; +const provider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, authUrl => { + captured.push(authUrl); +}); + +// ---- 1. Initial authorization for files:read ------------------------------ +const t1 = new StreamableHTTPClientTransport(new globalThis.URL(url), { authProvider: provider }); +let challenged = false; +try { + await client.connect(t1); +} catch (error) { + const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause; + if (!(root instanceof UnauthorizedError)) throw error; + challenged = true; +} +check.ok(challenged, 'first connect must 401'); +check.equal(captured.length, 1, 'authorize URL captured'); +check.match(captured[0]?.searchParams.get('scope') ?? '', /files:read/); +await t1.finishAuth(await followAuthorize(captured[0]!)); +check.equal(provider.tokens()?.scope, 'files:read'); + +// ---- 2. Reconnect with files:read; list-files works ----------------------- +const t2 = new StreamableHTTPClientTransport(new globalThis.URL(url), { authProvider: provider }); +await client.connect(t2); +const listed = await client.callTool({ name: 'list-files', arguments: {} }); +check.match(listed.content?.[0]?.type === 'text' ? listed.content[0].text : '', /listed by .* \[files:read]/); + +// ---- 3. write-file → handler-level insufficient_scope --------------------- +// Per-tool scope is enforced inside the tool handler (ctx.http?.authInfo), +// so an under-scoped call surfaces as a tool-result `isError`, not an HTTP +// 403. The transport's automatic step-up (SEP-2350) applies only when the +// RS responds 403 at the HTTP layer. +const denied = await client.callTool({ name: 'write-file', arguments: {} }); +check.equal(denied.isError, true, 'write-file must isError under files:read-only token'); +check.match(denied.content?.[0]?.type === 'text' ? denied.content[0].text : '', /insufficient_scope: requires files:write/); +check.equal(captured.length, 1, 'no transport step-up — scope is enforced in the tool handler'); + +await client.close(); diff --git a/examples/scoped-tools/package.json b/examples/scoped-tools/package.json new file mode 100644 index 0000000000..90a6cc9f8b --- /dev/null +++ b/examples/scoped-tools/package.json @@ -0,0 +1,29 @@ +{ + "name": "@mcp-examples/scoped-tools", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/oauth": "workspace:*", + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "modern", + "path": "/mcp", + "//": "Per-tool scope enforcement on createMcpHandler: HTTP gate does bearer-verify + 401 only; each tool handler checks ctx.http?.authInfo?.scopes and returns isError on miss. Modern-only because authInfo plumbing through ServerContext is the feature under demonstration." + } +} diff --git a/examples/scoped-tools/server.ts b/examples/scoped-tools/server.ts new file mode 100644 index 0000000000..5eee7f24d6 --- /dev/null +++ b/examples/scoped-tools/server.ts @@ -0,0 +1,213 @@ +/** + * Per-tool scoped Resource Server on `createMcpHandler`, plus a minimal + * in-process Authorization Server that issues tokens carrying whatever scope + * the client requested. + * + * One process, two listeners on adjacent ports: + * - `:PORT+1` — minimal AS: PRM/AS metadata, DCR, an `/authorize` endpoint + * that immediately 302s back to `redirect_uri?code=...` (the headless + * "auto-consent"), and a `/token` endpoint that issues a Bearer token whose + * granted scope mirrors the requested scope. + * - `:PORT` — MCP RS: `createMcpHandler` behind a bearer-verify gate (401 on + * missing/invalid token). Per-tool scope is enforced **inside each tool + * handler** via `ctx.http?.authInfo?.scopes` — the handler is the only + * place that authoritatively knows which tool is executing, so the scope + * decision lives next to the code it guards. An under-scoped call returns a + * tool-result `{ isError: true }` rather than an HTTP 403. + * + * DEMO ONLY — NOT FOR PRODUCTION. The AS auto-approves and issues whatever + * scope is asked for; tokens are validated in-process against the same AS. + * + * HTTP-only by definition (the OAuth dance is HTTP redirects + Bearer headers), + * so the canonical stdio branch does not apply. + */ +import { randomUUID } from 'node:crypto'; +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import type { AuthInfo } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const { port } = parseExampleArgs(); +const AS_PORT = port + 1; +const MCP_URL = `http://127.0.0.1:${port}/mcp`; +const AS_ISSUER = `http://127.0.0.1:${AS_PORT}`; + +// --------------------------------------------------------------------------- +// Minimal Authorization Server (DEMO ONLY) +// --------------------------------------------------------------------------- +/** code → requested scope (single-use). */ +const pendingCodes = new Map(); +/** access token → granted scope. */ +const issuedTokens = new Map(); +/** client_id → redirect_uris registered via DCR — `/authorize` MUST validate against this. */ +const registeredRedirectUris = new Map(); + +/** + * The demo AS only accepts loopback redirect URIs at registration time, so an + * unauthenticated DCR cannot register an external host and then have `/authorize` + * exfiltrate authorization codes to it. RFC 8252 §7.3 permits `http:` for loopback. + */ +const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']); +function isAllowedRedirectUri(raw: unknown): raw is string { + if (typeof raw !== 'string') return false; + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return false; + } + return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && LOOPBACK_HOSTS.has(parsed.hostname); +} + +const asServer = createServer((req, res) => { + const url = new URL(req.url ?? '/', AS_ISSUER); + const json = (status: number, body: unknown): void => { + res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(body)); + }; + if (url.pathname.startsWith('/.well-known/oauth-protected-resource')) { + return json(200, { resource: MCP_URL, authorization_servers: [AS_ISSUER], scopes_supported: ['files:read', 'files:write'] }); + } + if (url.pathname === '/.well-known/oauth-authorization-server' || url.pathname === '/.well-known/openid-configuration') { + return json(200, { + issuer: AS_ISSUER, + authorization_endpoint: `${AS_ISSUER}/authorize`, + token_endpoint: `${AS_ISSUER}/token`, + registration_endpoint: `${AS_ISSUER}/register`, + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + grant_types_supported: ['authorization_code'], + token_endpoint_auth_methods_supported: ['none'] + }); + } + if (url.pathname === '/register' && req.method === 'POST') { + let body = ''; + req.on('data', c => (body += String(c))); + req.on('end', () => { + // RFC 7591: echo the submitted metadata plus issued credentials. + const submitted = JSON.parse(body || '{}') as { redirect_uris?: unknown }; + const submittedUris = Array.isArray(submitted.redirect_uris) ? submitted.redirect_uris : []; + if (submittedUris.length === 0 || !submittedUris.every(u => isAllowedRedirectUri(u))) { + return json(400, { + error: 'invalid_redirect_uri', + error_description: 'this demo authorization server only accepts loopback redirect URIs' + }); + } + const clientId = `demo-${randomUUID().slice(0, 8)}`; + registeredRedirectUris.set(clientId, submittedUris); + json(201, { ...submitted, client_id: clientId, token_endpoint_auth_method: 'none' }); + }); + return; + } + if (url.pathname === '/authorize') { + // DEMO ONLY: auto-consent. A real AS would show a login + consent UI here. + // The redirect_uri MUST exactly match one registered for this client_id — + // never redirect to an unregistered URI (open-redirect → authorization-code leakage). + const clientId = url.searchParams.get('client_id') ?? ''; + const redirectUri = url.searchParams.get('redirect_uri') ?? ''; + const registered = registeredRedirectUris.get(clientId); + if (!registered || !registered.includes(redirectUri)) { + return json(400, { error: 'invalid_request', error_description: 'redirect_uri not registered for client_id' }); + } + const code = randomUUID(); + pendingCodes.set(code, url.searchParams.get('scope') ?? ''); + const redirect = new URL(redirectUri); + redirect.searchParams.set('code', code); + const state = url.searchParams.get('state'); + if (state) redirect.searchParams.set('state', state); + res.writeHead(302, { location: redirect.href }).end(); + return; + } + if (url.pathname === '/token' && req.method === 'POST') { + let body = ''; + req.on('data', c => (body += String(c))); + req.on('end', () => { + const params = new URLSearchParams(body); + const code = params.get('code') ?? ''; + const scope = pendingCodes.get(code); + if (scope === undefined) return json(400, { error: 'invalid_grant' }); + pendingCodes.delete(code); + const token = randomUUID(); + issuedTokens.set(token, scope); + json(200, { access_token: token, token_type: 'Bearer', scope, expires_in: 3600 }); + }); + return; + } + json(404, { error: 'not_found' }); +}); +asServer.listen(AS_PORT, '127.0.0.1', () => console.error(`[server] demo AS listening on ${AS_ISSUER}`)); + +// --------------------------------------------------------------------------- +// Resource Server (MCP) — bearer-verify at the gate, per-tool scope in handlers +// --------------------------------------------------------------------------- +function verifyBearer(header: string | null): AuthInfo | undefined { + if (!header?.startsWith('Bearer ')) return undefined; + const token = header.slice('Bearer '.length); + const scope = issuedTokens.get(token); + if (scope === undefined) return undefined; + return { token, clientId: 'scoped-tools-demo', scopes: scope.split(' ').filter(Boolean) }; +} + +/** + * Per-tool scope guard. The scope decision lives with the tool handler — the + * only place that authoritatively knows which tool is executing — rather than + * in HTTP middleware that would have to re-derive the operation from the + * request body. An under-scoped call returns a tool-level `isError` result. + */ +function requireScope( + authInfo: AuthInfo | undefined, + scope: string +): { isError: true; content: [{ type: 'text'; text: string }] } | undefined { + if (authInfo?.scopes.includes(scope)) return undefined; + return { isError: true, content: [{ type: 'text', text: `insufficient_scope: requires ${scope}` }] }; +} + +function buildServer(): McpServer { + const server = new McpServer({ name: 'scoped-tools', version: '1.0.0' }); + server.registerTool('list-files', { description: 'Requires files:read.', inputSchema: z.object({}) }, (_args, ctx) => { + const auth = ctx.http?.authInfo; + return ( + requireScope(auth, 'files:read') ?? { + content: [{ type: 'text', text: `listed by ${auth?.clientId} [${auth?.scopes.join(' ')}]` }] + } + ); + }); + server.registerTool('write-file', { description: 'Requires files:write.', inputSchema: z.object({}) }, (_args, ctx) => { + const auth = ctx.http?.authInfo; + return ( + requireScope(auth, 'files:write') ?? { + content: [{ type: 'text', text: `written by ${auth?.clientId} [${auth?.scopes.join(' ')}]` }] + } + ); + }); + return server; +} + +const handler = createMcpHandler(buildServer); +const node = toNodeHandler(handler); + +const app = createMcpExpressApp(); +// RFC 9728 PRM: the client discovers the AS from the 401 challenge → this route → AS metadata. +app.get('/.well-known/oauth-protected-resource/mcp', (_req, res) => { + res.json({ resource: MCP_URL, authorization_servers: [AS_ISSUER], scopes_supported: ['files:read', 'files:write'] }); +}); +app.all('/mcp', (req, res) => { + const authInfo = verifyBearer(req.headers.authorization ?? null); + if (!authInfo) { + res.set( + 'www-authenticate', + `Bearer resource_metadata="http://127.0.0.1:${port}/.well-known/oauth-protected-resource/mcp", scope="files:read"` + ); + res.status(401).json({ error: 'invalid_token' }); + return; + } + // toNodeHandler reads `req.auth` and forwards it as the entry's pass-through authInfo; + // per-tool scope is enforced inside each tool handler via ctx.http?.authInfo. + req.auth = authInfo; + void node(req, res, req.body); +}); + +app.listen(port, '127.0.0.1', () => console.error(`[server] MCP RS listening on ${MCP_URL}`)); diff --git a/examples/server/README.md b/examples/server/README.md deleted file mode 100644 index a71e63a7d5..0000000000 --- a/examples/server/README.md +++ /dev/null @@ -1,172 +0,0 @@ -# MCP TypeScript SDK Examples (Server) - -This directory contains runnable MCP **server** examples built with `@modelcontextprotocol/server` plus framework adapters: - -- `@modelcontextprotocol/express` -- `@modelcontextprotocol/hono` - -For client examples, see [`../client/README.md`](../client/README.md). For guided docs, see [`../../docs/server.md`](../../docs/server.md). - -## Running examples - -From anywhere in the SDK: - -```bash -pnpm install -pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts -``` - -Or, from within this package: - -```bash -cd examples/server -pnpm tsx src/simpleStreamableHttp.ts -``` - -## Example index - -| Scenario | Description | File | -| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| Streamable HTTP server (stateful) | Feature-rich server with tools/resources/prompts, logging, sampling, and optional OAuth. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) | -| Streamable HTTP server (stateless) | No session tracking; good for simple API-style servers. | [`src/simpleStatelessStreamableHttp.ts`](src/simpleStatelessStreamableHttp.ts) | -| Resource-Server-only auth | Minimal OAuth RS using `mcpAuthMetadataRouter` + `requireBearerAuth` from `@modelcontextprotocol/express` (no better-auth). | [`src/resourceServerOnly.ts`](src/resourceServerOnly.ts) | -| JSON response mode (no SSE) | Streamable HTTP with JSON-only responses and limited notifications. | [`src/jsonResponseStreamableHttp.ts`](src/jsonResponseStreamableHttp.ts) | -| Server notifications over Streamable HTTP | Demonstrates server-initiated notifications via GET+SSE. | [`src/standaloneSseWithGetStreamableHttp.ts`](src/standaloneSseWithGetStreamableHttp.ts) | -| Output schema server | Demonstrates tool output validation with structured output schemas. | [`src/mcpServerOutputSchema.ts`](src/mcpServerOutputSchema.ts) | -| Form elicitation server | Collects **non-sensitive** user input via schema-driven forms. | [`src/elicitationFormExample.ts`](src/elicitationFormExample.ts) | -| URL elicitation server | Secure browser-based flows for **sensitive** input (API keys, OAuth, payments). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | -| Sampling server | Demonstrates server-initiated sampling requests. | [`src/toolWithSampleServer.ts`](src/toolWithSampleServer.ts) | -| Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) | -| SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) | - -## OAuth demo flags (Streamable HTTP server) - -```bash -pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts --oauth -``` - -## URL elicitation example (server + client) - -Run the server: - -```bash -pnpm --filter @modelcontextprotocol/examples-server exec tsx src/elicitationUrlExample.ts -``` - -Run the client in another terminal: - -```bash -pnpm --filter @modelcontextprotocol/examples-client exec tsx src/elicitationUrlExample.ts -``` - -## Multi-node deployment patterns - -When deploying MCP servers in a horizontally scaled environment (multiple server instances), there are a few different options that can be useful for different use cases: - -- **Stateless mode** - no need to maintain state between calls. -- **Persistent storage mode** - state stored in a database; any node can handle a session. -- **Local state with message routing** - stateful nodes + pub/sub routing for a session. - -### Stateless mode - -To enable stateless mode, configure the `NodeStreamableHTTPServerTransport` with: - -```typescript -sessionIdGenerator: undefined; -``` - -``` -┌─────────────────────────────────────────────┐ -│ Client │ -└─────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────┐ -│ Load Balancer │ -└─────────────────────────────────────────────┘ - │ │ - ▼ ▼ -┌─────────────────┐ ┌─────────────────────┐ -│ MCP Server #1 │ │ MCP Server #2 │ -│ (Node.js) │ │ (Node.js) │ -└─────────────────┘ └─────────────────────┘ -``` - -### Persistent storage mode - -Configure the transport with session management, but use an external event store: - -```typescript -sessionIdGenerator: () => randomUUID(), -eventStore: databaseEventStore -``` - -``` -┌─────────────────────────────────────────────┐ -│ Client │ -└─────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────┐ -│ Load Balancer │ -└─────────────────────────────────────────────┘ - │ │ - ▼ ▼ -┌─────────────────┐ ┌─────────────────────┐ -│ MCP Server #1 │ │ MCP Server #2 │ -│ (Node.js) │ │ (Node.js) │ -└─────────────────┘ └─────────────────────┘ - │ │ - │ │ - ▼ ▼ -┌─────────────────────────────────────────────┐ -│ Database (PostgreSQL) │ -│ │ -│ • Session state │ -│ • Event storage for resumability │ -└─────────────────────────────────────────────┘ -``` - -### Streamable HTTP with distributed message routing - -For scenarios where local in-memory state must be maintained on specific nodes, combine Streamable HTTP with pub/sub routing so one node can terminate the client connection while another node owns the session state. - -``` -┌─────────────────────────────────────────────┐ -│ Client │ -└─────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────┐ -│ Load Balancer │ -└─────────────────────────────────────────────┘ - │ │ - ▼ ▼ -┌─────────────────┐ ┌─────────────────────┐ -│ MCP Server #1 │◄───►│ MCP Server #2 │ -│ (Has Session A) │ │ (Has Session B) │ -└─────────────────┘ └─────────────────────┘ - ▲│ ▲│ - │▼ │▼ -┌─────────────────────────────────────────────┐ -│ Message Queue / Pub-Sub │ -│ │ -│ • Session ownership registry │ -│ • Bidirectional message routing │ -│ • Request/response forwarding │ -└─────────────────────────────────────────────┘ -``` - -## Backwards compatibility (Streamable HTTP ↔ legacy SSE) - -Start the server: - -```bash -pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts -``` - -Then run the backwards-compatible client: - -```bash -pnpm --filter @modelcontextprotocol/examples-client exec tsx src/streamableHttpWithSseFallbackClient.ts -``` diff --git a/examples/server/eslint.config.mjs b/examples/server/eslint.config.mjs deleted file mode 100644 index 83b79879f6..0000000000 --- a/examples/server/eslint.config.mjs +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-check - -import baseConfig from '@modelcontextprotocol/eslint-config'; - -export default [ - ...baseConfig, - { - files: ['src/**/*.{ts,tsx,js,jsx,mts,cts}'], - rules: { - // Allow console statements in examples only - 'no-console': 'off' - } - } -]; diff --git a/examples/server/src/arktypeExample.ts b/examples/server/src/arktypeExample.ts deleted file mode 100644 index 4a470532ed..0000000000 --- a/examples/server/src/arktypeExample.ts +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env node -/** - * Minimal MCP server using ArkType for schema validation. - * ArkType implements the Standard Schema spec with built-in JSON Schema conversion. - */ - -import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import { type } from 'arktype'; - -const server = new McpServer({ - name: 'arktype-example', - version: '1.0.0' -}); - -// Register a tool with ArkType schema -server.registerTool( - 'greet', - { - description: 'Generate a greeting', - inputSchema: type({ name: 'string' }) - }, - async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) -); - -const transport = new StdioServerTransport(); -await server.connect(transport); diff --git a/examples/server/src/customMethodExample.ts b/examples/server/src/customMethodExample.ts deleted file mode 100644 index 6968a26e6c..0000000000 --- a/examples/server/src/customMethodExample.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Custom (non-spec) method example: a server that handles a vendor-prefixed - * `acme/search` request and emits `acme/searchProgress` notifications. - * - * Spawned via stdio by `examples/client/src/customMethodExample.ts`; do not run standalone. - */ -import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import { z } from 'zod/v4'; - -const SearchParams = z.object({ query: z.string(), limit: z.number().int().default(10) }); -const SearchResult = z.object({ items: z.array(z.string()) }); - -const mcp = new McpServer({ name: 'acme-search', version: '0.0.0' }); - -mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => { - await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } }); - const items = Array.from({ length: params.limit }, (_, i) => `${params.query}-${i}`); - await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'done', pct: 1 } }); - return { items }; -}); - -await mcp.connect(new StdioServerTransport()); diff --git a/examples/server/src/customProtocolVersion.ts b/examples/server/src/customProtocolVersion.ts deleted file mode 100644 index c580432e4b..0000000000 --- a/examples/server/src/customProtocolVersion.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Example: Custom Protocol Version Support - * - * This demonstrates how to support protocol versions not yet in the SDK. - * First version in the list is used as fallback when client requests - * an unsupported version. - * - * Run with: pnpm tsx src/customProtocolVersion.ts - */ - -import { randomUUID } from 'node:crypto'; -import { createServer } from 'node:http'; - -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { CallToolResult } from '@modelcontextprotocol/server'; -import { McpServer, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server'; - -// Add support for a newer protocol version (first in list is fallback) -const CUSTOM_VERSIONS = ['2026-01-01', ...SUPPORTED_PROTOCOL_VERSIONS]; - -const server = new McpServer( - { name: 'custom-protocol-server', version: '1.0.0' }, - { - supportedProtocolVersions: CUSTOM_VERSIONS, - capabilities: { tools: {} } - } -); - -// Register a tool that shows the protocol configuration -server.registerTool( - 'get-protocol-info', - { - title: 'Protocol Info', - description: 'Returns protocol version configuration' - }, - async (): Promise => ({ - content: [ - { - type: 'text', - text: JSON.stringify({ supportedVersions: CUSTOM_VERSIONS }, null, 2) - } - ] - }) -); - -// Create transport - server passes versions automatically during connect() -const transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID() -}); - -await server.connect(transport); - -// Simple HTTP server -const PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000; - -createServer(async (req, res) => { - if (req.url === '/mcp') { - await transport.handleRequest(req, res); - } else { - res.writeHead(404).end('Not Found'); - } -}).listen(PORT, () => { - console.log(`MCP server with custom protocol versions on port ${PORT}`); - console.log(`Supported versions: ${CUSTOM_VERSIONS.join(', ')}`); -}); diff --git a/examples/server/src/elicitationFormExample.ts b/examples/server/src/elicitationFormExample.ts deleted file mode 100644 index e059e8452d..0000000000 --- a/examples/server/src/elicitationFormExample.ts +++ /dev/null @@ -1,488 +0,0 @@ -// Run with: pnpm tsx src/elicitationFormExample.ts -// -// This example demonstrates how to use form elicitation to collect structured user input -// with JSON Schema validation via a local HTTP server with SSE streaming. -// Form elicitation allows servers to request *non-sensitive* user input through the client -// with schema-based validation. -// Note: See also elicitationUrlExample.ts for an example of using URL elicitation -// to collect *sensitive* user input via a browser. - -import { randomUUID } from 'node:crypto'; - -import { createMcpExpressApp } from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; -import type { Request, Response } from 'express'; - -// Create a fresh MCP server per client connection to avoid shared state between clients. -// The validator supports format validation (email, date, etc.) if ajv-formats is installed. -const getServer = () => { - const mcpServer = new McpServer( - { - name: 'form-elicitation-example-server', - version: '1.0.0' - }, - { - capabilities: {} - } - ); - - /** - * Example 1: Simple user registration tool - * Collects username, email, and password from the user - */ - mcpServer.registerTool( - 'register_user', - { - description: 'Register a new user account by collecting their information' - }, - async () => { - try { - // Request user information through form elicitation - const result = await mcpServer.server.elicitInput({ - mode: 'form', - message: 'Please provide your registration information:', - requestedSchema: { - type: 'object', - properties: { - username: { - type: 'string', - title: 'Username', - description: 'Your desired username (3-20 characters)', - minLength: 3, - maxLength: 20 - }, - email: { - type: 'string', - title: 'Email', - description: 'Your email address', - format: 'email' - }, - password: { - type: 'string', - title: 'Password', - description: 'Your password (min 8 characters)', - minLength: 8 - }, - newsletter: { - type: 'boolean', - title: 'Newsletter', - description: 'Subscribe to newsletter?', - default: false - } - }, - required: ['username', 'email', 'password'] - } - }); - - // Handle the different possible actions - if (result.action === 'accept' && result.content) { - const { username, email, newsletter } = result.content as { - username: string; - email: string; - password: string; - newsletter?: boolean; - }; - - return { - content: [ - { - type: 'text', - text: `Registration successful!\n\nUsername: ${username}\nEmail: ${email}\nNewsletter: ${newsletter ? 'Yes' : 'No'}` - } - ] - }; - } else if (result.action === 'decline') { - return { - content: [ - { - type: 'text', - text: 'Registration cancelled by user.' - } - ] - }; - } else { - return { - content: [ - { - type: 'text', - text: 'Registration was cancelled.' - } - ] - }; - } - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Registration failed: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); - - /** - * Example 2: Multi-step workflow with multiple form elicitation requests - * Demonstrates how to collect information in multiple steps - */ - mcpServer.registerTool( - 'create_event', - { - description: 'Create a calendar event by collecting event details' - }, - async () => { - try { - // Step 1: Collect basic event information - const basicInfo = await mcpServer.server.elicitInput({ - mode: 'form', - message: 'Step 1: Enter basic event information', - requestedSchema: { - type: 'object', - properties: { - title: { - type: 'string', - title: 'Event Title', - description: 'Name of the event', - minLength: 1 - }, - description: { - type: 'string', - title: 'Description', - description: 'Event description (optional)' - } - }, - required: ['title'] - } - }); - - if (basicInfo.action !== 'accept' || !basicInfo.content) { - return { - content: [{ type: 'text', text: 'Event creation cancelled.' }] - }; - } - - // Step 2: Collect date and time - const dateTime = await mcpServer.server.elicitInput({ - mode: 'form', - message: 'Step 2: Enter date and time', - requestedSchema: { - type: 'object', - properties: { - date: { - type: 'string', - title: 'Date', - description: 'Event date', - format: 'date' - }, - startTime: { - type: 'string', - title: 'Start Time', - description: 'Event start time (HH:MM)' - }, - duration: { - type: 'integer', - title: 'Duration', - description: 'Duration in minutes', - minimum: 15, - maximum: 480 - } - }, - required: ['date', 'startTime', 'duration'] - } - }); - - if (dateTime.action !== 'accept' || !dateTime.content) { - return { - content: [{ type: 'text', text: 'Event creation cancelled.' }] - }; - } - - // Combine all collected information - const event = { - ...basicInfo.content, - ...dateTime.content - }; - - return { - content: [ - { - type: 'text', - text: `Event created successfully!\n\n${JSON.stringify(event, null, 2)}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Event creation failed: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); - - /** - * Example 3: Collecting address information - * Demonstrates validation with patterns and optional fields - */ - mcpServer.registerTool( - 'update_shipping_address', - { - description: 'Update shipping address with validation' - }, - async () => { - try { - const result = await mcpServer.server.elicitInput({ - mode: 'form', - message: 'Please provide your shipping address:', - requestedSchema: { - type: 'object', - properties: { - name: { - type: 'string', - title: 'Full Name', - description: 'Recipient name', - minLength: 1 - }, - street: { - type: 'string', - title: 'Street Address', - minLength: 1 - }, - city: { - type: 'string', - title: 'City', - minLength: 1 - }, - state: { - type: 'string', - title: 'State/Province', - minLength: 2, - maxLength: 2 - }, - zipCode: { - type: 'string', - title: 'ZIP/Postal Code', - description: '5-digit ZIP code' - }, - phone: { - type: 'string', - title: 'Phone Number (optional)', - description: 'Contact phone number' - } - }, - required: ['name', 'street', 'city', 'state', 'zipCode'] - } - }); - - if (result.action === 'accept' && result.content) { - return { - content: [ - { - type: 'text', - text: `Address updated successfully!\n\n${JSON.stringify(result.content, null, 2)}` - } - ] - }; - } else if (result.action === 'decline') { - return { - content: [{ type: 'text', text: 'Address update cancelled by user.' }] - }; - } else { - return { - content: [{ type: 'text', text: 'Address update was cancelled.' }] - }; - } - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Address update failed: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } - } - ); - - return mcpServer; -}; - -async function main() { - const PORT = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000; - - const app = createMcpExpressApp(); - - // Map to store transports by session ID - const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; - - // MCP POST endpoint - const mcpPostHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (sessionId) { - console.log(`Received MCP request for session: ${sessionId}`); - } - - try { - let transport: NodeStreamableHTTPServerTransport; - if (sessionId && transports[sessionId]) { - // Reuse existing transport for this session - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - // New initialization request - create new transport - transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: sessionId => { - // Store the transport by session ID when session is initialized - console.log(`Session initialized with ID: ${sessionId}`); - transports[sessionId] = transport; - } - }); - - // Set up onclose handler to clean up transport when closed - transport.onclose = () => { - const sid = transport.sessionId; - if (sid && transports[sid]) { - console.log(`Transport closed for session ${sid}, removing from transports map`); - delete transports[sid]; - } - }; - - // Connect a fresh MCP server to the transport BEFORE handling the request - const mcpServer = getServer(); - await mcpServer.connect(transport); - - await transport.handleRequest(req, res, req.body); - return; - } else if (sessionId) { - res.status(404).json({ - jsonrpc: '2.0', - error: { code: -32_001, message: 'Session not found' }, - id: null - }); - return; - } else { - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32_000, message: 'Bad Request: Session ID required' }, - id: null - }); - return; - } - - // Handle the request with existing transport - await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32_603, - message: 'Internal server error' - }, - id: null - }); - } - } - }; - - app.post('/mcp', mcpPostHandler); - - // Handle GET requests for SSE streams - const mcpGetHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - console.log(`Establishing SSE stream for session ${sessionId}`); - const transport = transports[sessionId]; - await transport.handleRequest(req, res); - }; - - app.get('/mcp', mcpGetHandler); - - // Handle DELETE requests for session termination - const mcpDeleteHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - console.log(`Received session termination request for session ${sessionId}`); - - try { - const transport = transports[sessionId]; - await transport.handleRequest(req, res); - } catch (error) { - console.error('Error handling session termination:', error); - if (!res.headersSent) { - res.status(500).send('Error processing session termination'); - } - } - }; - - app.delete('/mcp', mcpDeleteHandler); - - // Start listening - 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(`Form elicitation example server is running on http://localhost:${PORT}/mcp`); - console.log('Available tools:'); - console.log(' - register_user: Collect user registration information'); - console.log(' - create_event: Multi-step event creation'); - console.log(' - update_shipping_address: Collect and validate address'); - console.log('\nConnect your MCP client to this server using the HTTP transport.'); - }); - - // Handle server shutdown - process.on('SIGINT', async () => { - console.log('Shutting down server...'); - - // Close all active transports to properly clean up resources - for (const sessionId in transports) { - try { - console.log(`Closing transport for session ${sessionId}`); - await transports[sessionId]!.close(); - delete transports[sessionId]; - } catch (error) { - console.error(`Error closing transport for session ${sessionId}:`, error); - } - } - console.log('Server shutdown complete'); - process.exit(0); - }); -} - -try { - await main(); -} catch (error) { - console.error('Server error:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/server/src/elicitationUrlExample.ts b/examples/server/src/elicitationUrlExample.ts deleted file mode 100644 index 93b59152f8..0000000000 --- a/examples/server/src/elicitationUrlExample.ts +++ /dev/null @@ -1,738 +0,0 @@ -// Run with: pnpm tsx src/elicitationUrlExample.ts -// -// This example demonstrates how to use URL elicitation to securely collect -// *sensitive* user input in a remote (HTTP) server. -// URL elicitation allows servers to prompt the end-user to open a URL in their browser -// to collect sensitive information. -// Note: See also elicitationFormExample.ts for an example of using form (not URL) elicitation -// to collect *non-sensitive* user input with a structured schema. - -import { randomUUID } from 'node:crypto'; - -import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@modelcontextprotocol/examples-shared'; -import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { CallToolResult, ElicitRequestURLParams, ElicitResult } from '@modelcontextprotocol/server'; -import { isInitializeRequest, McpServer, UrlElicitationRequiredError } from '@modelcontextprotocol/server'; -import cors from 'cors'; -import type { Request, Response } from 'express'; -import express from 'express'; -import * as z from 'zod/v4'; - -import { InMemoryEventStore } from './inMemoryEventStore.js'; - -// Create an MCP server with implementation details -const getServer = () => { - const mcpServer = new McpServer( - { - name: 'url-elicitation-http-server', - version: '1.0.0' - }, - { - capabilities: { logging: {} } - } - ); - - mcpServer.registerTool( - 'payment-confirm', - { - description: 'A tool that confirms a payment directly with a user', - inputSchema: z.object({ - cartId: z.string().describe('The ID of the cart to confirm') - }) - }, - async ({ cartId }, ctx): Promise => { - /* - In a real world scenario, there would be some logic here to check if the user has the provided cartId. - For the purposes of this example, we'll throw an error (-> elicits the client to open a URL to confirm payment) - */ - const sessionId = ctx.sessionId; - if (!sessionId) { - throw new Error('Expected a Session ID'); - } - - // Create and track the elicitation - const elicitationId = generateTrackedElicitation(sessionId, elicitationId => - mcpServer.server.createElicitationCompletionNotifier(elicitationId) - ); - throw new UrlElicitationRequiredError([ - { - mode: 'url', - message: 'This tool requires a payment confirmation. Open the link to confirm payment!', - url: `http://localhost:${MCP_PORT}/confirm-payment?session=${sessionId}&elicitation=${elicitationId}&cartId=${encodeURIComponent(cartId)}`, - elicitationId - } - ]); - } - ); - - mcpServer.registerTool( - 'third-party-auth', - { - description: 'A demo tool that requires third-party OAuth credentials', - inputSchema: z.object({ - param1: z.string().describe('First parameter') - }) - }, - async (_, ctx): Promise => { - /* - In a real world scenario, there would be some logic here to check if we already have a valid access token for the user. - Auth info (with a subject or `sub` claim) can be typically be found in `ctx.http?.authInfo`. - If we do, we can just return the result of the tool call. - If we don't, we can throw an ElicitationRequiredError to request the user to authenticate. - For the purposes of this example, we'll throw an error (-> elicits the client to open a URL to authenticate). - */ - const sessionId = ctx.sessionId; - if (!sessionId) { - throw new Error('Expected a Session ID'); - } - - // Create and track the elicitation - const elicitationId = generateTrackedElicitation(sessionId, elicitationId => - mcpServer.server.createElicitationCompletionNotifier(elicitationId) - ); - - // Simulate OAuth callback and token exchange after 5 seconds - // In a real app, this would be called from your OAuth callback handler - setTimeout(() => { - console.log(`Simulating OAuth token received for elicitation ${elicitationId}`); - completeURLElicitation(elicitationId); - }, 5000); - - throw new UrlElicitationRequiredError([ - { - mode: 'url', - message: 'This tool requires access to your example.com account. Open the link to authenticate!', - url: 'https://www.example.com/oauth/authorize', - elicitationId - } - ]); - } - ); - - return mcpServer; -}; - -/** - * Elicitation Completion Tracking Utilities - **/ - -interface ElicitationMetadata { - status: 'pending' | 'complete'; - completedPromise: Promise; - completeResolver: () => void; - createdAt: Date; - sessionId: string; - completionNotifier?: () => Promise; -} - -const elicitationsMap = new Map(); - -// Clean up old elicitations after 1 hour to prevent memory leaks -const ELICITATION_TTL_MS = 60 * 60 * 1000; // 1 hour -const CLEANUP_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes - -function cleanupOldElicitations() { - const now = new Date(); - for (const [id, metadata] of elicitationsMap.entries()) { - if (now.getTime() - metadata.createdAt.getTime() > ELICITATION_TTL_MS) { - elicitationsMap.delete(id); - console.log(`Cleaned up expired elicitation: ${id}`); - } - } -} - -setInterval(cleanupOldElicitations, CLEANUP_INTERVAL_MS); - -/** - * Elicitation IDs must be unique strings within the MCP session - * UUIDs are used in this example for simplicity - */ -function generateElicitationId(): string { - return randomUUID(); -} - -/** - * Helper function to create and track a new elicitation. - */ -function generateTrackedElicitation(sessionId: string, createCompletionNotifier?: ElicitationCompletionNotifierFactory): string { - const elicitationId = generateElicitationId(); - - // Create a Promise and its resolver for tracking completion - let completeResolver: () => void; - const completedPromise = new Promise(resolve => { - completeResolver = resolve; - }); - - const completionNotifier = createCompletionNotifier ? createCompletionNotifier(elicitationId) : undefined; - - // Store the elicitation in our map - elicitationsMap.set(elicitationId, { - status: 'pending', - completedPromise, - completeResolver: completeResolver!, - createdAt: new Date(), - sessionId, - completionNotifier - }); - - return elicitationId; -} - -/** - * Helper function to complete an elicitation. - */ -function completeURLElicitation(elicitationId: string) { - const elicitation = elicitationsMap.get(elicitationId); - if (!elicitation) { - console.warn(`Attempted to complete unknown elicitation: ${elicitationId}`); - return; - } - - if (elicitation.status === 'complete') { - console.warn(`Elicitation already complete: ${elicitationId}`); - return; - } - - // Update metadata - elicitation.status = 'complete'; - - // Send completion notification to the client - if (elicitation.completionNotifier) { - console.log(`Sending notifications/elicitation/complete notification for elicitation ${elicitationId}`); - - elicitation.completionNotifier().catch(error => { - console.error(`Failed to send completion notification for elicitation ${elicitationId}:`, error); - }); - } - - // Resolve the promise to unblock any waiting code - elicitation.completeResolver(); -} - -const MCP_PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000; -const AUTH_PORT = process.env.MCP_AUTH_PORT ? Number.parseInt(process.env.MCP_AUTH_PORT, 10) : 3001; - -const app = createMcpExpressApp(); - -// Allow CORS all domains, expose the Mcp-Session-Id header -app.use( - cors({ - origin: '*', // Allow all origins - exposedHeaders: ['Mcp-Session-Id'], - credentials: true // Allow cookies to be sent cross-origin - }) -); - -// Set up OAuth (required for this example) -let authMiddleware = null; -// Create auth middleware for MCP endpoints -const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`); -const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); - -setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true }); - -// Add protected resource metadata route to the MCP server -// This allows clients to discover the auth server -// Pass the resource path so metadata is served at /.well-known/oauth-protected-resource/mcp -app.use(createProtectedResourceMetadataRouter('/mcp')); - -authMiddleware = requireBearerAuth({ - verifier: demoTokenVerifier, - requiredScopes: [], - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) -}); - -/** - * API Key Form Handling - * - * Many servers today require an API key to operate, but there's no scalable way to do this dynamically for remote servers within MCP protocol. - * URL-mode elicitation enables the server to host a simple form and get the secret data securely from the user without involving the LLM or client. - **/ - -async function sendApiKeyElicitation( - sessionId: string, - sender: ElicitationSender, - createCompletionNotifier: ElicitationCompletionNotifierFactory -) { - if (!sessionId) { - console.error('No session ID provided'); - throw new Error('Expected a Session ID to track elicitation'); - } - - console.log('🔑 URL elicitation demo: Requesting API key from client...'); - const elicitationId = generateTrackedElicitation(sessionId, createCompletionNotifier); - try { - const result = await sender({ - mode: 'url', - message: 'Please provide your API key to authenticate with this server', - // Host the form on the same server. In a real app, you might coordinate passing these state variables differently. - url: `http://localhost:${MCP_PORT}/api-key-form?session=${sessionId}&elicitation=${elicitationId}`, - elicitationId - }); - - switch (result.action) { - case 'accept': { - console.log('🔑 URL elicitation demo: Client accepted the API key elicitation (now pending form submission)'); - // Wait for the API key to be submitted via the form - // The form submission will complete the elicitation - break; - } - default: { - console.log('🔑 URL elicitation demo: Client declined to provide an API key'); - // In a real app, this might close the connection, but for the demo, we'll continue - break; - } - } - } catch (error) { - console.error('Error during API key elicitation:', error); - } -} - -// API Key Form endpoint - serves a simple HTML form -app.get('/api-key-form', (req: Request, res: Response) => { - const mcpSessionId = req.query.session as string | undefined; - const elicitationId = req.query.elicitation as string | undefined; - if (!mcpSessionId || !elicitationId) { - res.status(400).send('

Error

Missing required parameters

'); - return; - } - - // Check for user session cookie - // In production, this is often handled by some user auth middleware to ensure the user has a valid session - // This session is different from the MCP session. - // This userSession is the cookie that the MCP Server's Authorization Server sets for the user when they log in. - const userSession = getUserSessionCookie(req.headers.cookie); - if (!userSession) { - res.status(401).send('

Error

Unauthorized - please reconnect to login again

'); - return; - } - - // Serve a simple HTML form - res.send(` - - - - Submit Your API Key - - - -

API Key Required

-
✓ Logged in as: ${userSession.name}
-
- - - - -
-
This is a demo showing how a server can securely elicit sensitive data from a user using a URL.
- - - `); -}); - -// Handle API key form submission -app.post('/api-key-form', express.urlencoded(), (req: Request, res: Response) => { - const { session: sessionId, apiKey, elicitation: elicitationId } = req.body; - if (!sessionId || !apiKey || !elicitationId) { - res.status(400).send('

Error

Missing required parameters

'); - return; - } - - // Check for user session cookie here too - const userSession = getUserSessionCookie(req.headers.cookie); - if (!userSession) { - res.status(401).send('

Error

Unauthorized - please reconnect to login again

'); - return; - } - - // A real app might store this API key to be used later for the user. - console.log(`🔑 Received API key \u001B[32m${apiKey}\u001B[0m for session ${sessionId}`); - - // If we have an elicitationId, complete the elicitation - completeURLElicitation(elicitationId); - - // Send a success response - res.send(` - - - - Success - - - -
-

Success ✓

-

API key received.

-
-

You can close this window and return to your MCP client.

- - - `); -}); - -// Helper to get the user session from the demo_session cookie -function getUserSessionCookie(cookieHeader?: string): { userId: string; name: string; timestamp: number } | null { - if (!cookieHeader) return null; - - const cookies = cookieHeader.split(';'); - for (const cookie of cookies) { - const [name, value] = cookie.trim().split('='); - if (name === 'demo_session' && value) { - try { - return JSON.parse(decodeURIComponent(value)); - } catch (error) { - console.error('Failed to parse demo_session cookie:', error); - return null; - } - } - } - return null; -} - -/** - * Payment Confirmation Form Handling - * - * This demonstrates how a server can use URL-mode elicitation to get user confirmation - * for sensitive operations like payment processing. - **/ - -// Payment Confirmation Form endpoint - serves a simple HTML form -app.get('/confirm-payment', (req: Request, res: Response) => { - const mcpSessionId = req.query.session as string | undefined; - const elicitationId = req.query.elicitation as string | undefined; - const cartId = req.query.cartId as string | undefined; - if (!mcpSessionId || !elicitationId) { - res.status(400).send('

Error

Missing required parameters

'); - return; - } - - // Check for user session cookie - // In production, this is often handled by some user auth middleware to ensure the user has a valid session - // This session is different from the MCP session. - // This userSession is the cookie that the MCP Server's Authorization Server sets for the user when they log in. - const userSession = getUserSessionCookie(req.headers.cookie); - if (!userSession) { - res.status(401).send('

Error

Unauthorized - please reconnect to login again

'); - return; - } - - // Serve a simple HTML form - res.send(` - - - - Confirm Payment - - - -

Confirm Payment

-
✓ Logged in as: ${userSession.name}
- ${cartId ? `
Cart ID: ${cartId}
` : ''} -
- ⚠️ Please review your order before confirming. -
-
- - - ${cartId ? `` : ''} - - -
-
This is a demo showing how a server can securely get user confirmation for sensitive operations using URL-mode elicitation.
- - - `); -}); - -// Handle Payment Confirmation form submission -app.post('/confirm-payment', express.urlencoded(), (req: Request, res: Response) => { - const { session: sessionId, elicitation: elicitationId, cartId, action } = req.body; - if (!sessionId || !elicitationId) { - res.status(400).send('

Error

Missing required parameters

'); - return; - } - - // Check for user session cookie here too - const userSession = getUserSessionCookie(req.headers.cookie); - if (!userSession) { - res.status(401).send('

Error

Unauthorized - please reconnect to login again

'); - return; - } - - if (action === 'confirm') { - // A real app would process the payment here - console.log(`💳 Payment confirmed for cart ${cartId || 'unknown'} by user ${userSession.name} (session ${sessionId})`); - - // Complete the elicitation - completeURLElicitation(elicitationId); - - // Send a success response - res.send(` - - - - Payment Confirmed - - - -
-

Payment Confirmed ✓

-

Your payment has been successfully processed.

- ${cartId ? `

Cart ID: ${cartId}

` : ''} -
-

You can close this window and return to your MCP client.

- - - `); - } else if (action === 'cancel') { - console.log(`💳 Payment cancelled for cart ${cartId || 'unknown'} by user ${userSession.name} (session ${sessionId})`); - - // The client will still receive a notifications/elicitation/complete notification, - // which indicates that the out-of-band interaction is complete (but not necessarily successful) - completeURLElicitation(elicitationId); - - res.send(` - - - - Payment Cancelled - - - -
-

Payment Cancelled

-

Your payment has been cancelled.

-
-

You can close this window and return to your MCP client.

- - - `); - } else { - res.status(400).send('

Error

Invalid action

'); - } -}); - -// Map to store transports by session ID -const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; - -// Interface for a function that can send an elicitation request -type ElicitationSender = (params: ElicitRequestURLParams) => Promise; -type ElicitationCompletionNotifierFactory = (elicitationId: string) => () => Promise; - -// Track sessions that need an elicitation request to be sent -interface SessionElicitationInfo { - elicitationSender: ElicitationSender; - createCompletionNotifier: ElicitationCompletionNotifierFactory; -} -const sessionsNeedingElicitation: { [sessionId: string]: SessionElicitationInfo } = {}; - -// MCP POST endpoint -const mcpPostHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - console.debug(`Received MCP POST for session: ${sessionId || 'unknown'}`); - - try { - let transport: NodeStreamableHTTPServerTransport; - if (sessionId && transports[sessionId]) { - // Reuse existing transport - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - const server = getServer(); - // New initialization request - const eventStore = new InMemoryEventStore(); - transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - eventStore, // Enable resumability - onsessioninitialized: sessionId => { - // Store the transport by session ID when session is initialized - // This avoids race conditions where requests might come in before the session is stored - console.log(`Session initialized with ID: ${sessionId}`); - transports[sessionId] = transport; - sessionsNeedingElicitation[sessionId] = { - elicitationSender: params => server.server.elicitInput(params), - createCompletionNotifier: elicitationId => server.server.createElicitationCompletionNotifier(elicitationId) - }; - } - }); - - // Set up onclose handler to clean up transport when closed - transport.onclose = () => { - const sid = transport.sessionId; - if (sid && transports[sid]) { - console.log(`Transport closed for session ${sid}, removing from transports map`); - delete transports[sid]; - delete sessionsNeedingElicitation[sid]; - } - }; - - // Connect the transport to the MCP server BEFORE handling the request - // so responses can flow back through the same transport - await server.connect(transport); - - await transport.handleRequest(req, res, req.body); - return; // Already handled - } else if (sessionId) { - res.status(404).json({ - jsonrpc: '2.0', - error: { code: -32_001, message: 'Session not found' }, - id: null - }); - return; - } else { - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32_000, message: 'Bad Request: Session ID required' }, - id: null - }); - return; - } - - // Handle the request with existing transport - no need to reconnect - // The existing transport is already connected to the server - await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32_603, - message: 'Internal server error' - }, - id: null - }); - } - } -}; - -// Set up routes with auth middleware -app.post('/mcp', authMiddleware, mcpPostHandler); - -// Handle GET requests for SSE streams (using built-in support from StreamableHTTP) -const mcpGetHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - // Check for Last-Event-ID header for resumability - const lastEventId = req.headers['last-event-id'] as string | undefined; - if (lastEventId) { - console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); - } else { - console.log(`Establishing new SSE stream for session ${sessionId}`); - } - - const transport = transports[sessionId]; - await transport.handleRequest(req, res); - - if (sessionsNeedingElicitation[sessionId]) { - const { elicitationSender, createCompletionNotifier } = sessionsNeedingElicitation[sessionId]; - - // Send an elicitation request to the client in the background - sendApiKeyElicitation(sessionId, elicitationSender, createCompletionNotifier) - .then(() => { - // Only delete on successful send for this demo - delete sessionsNeedingElicitation[sessionId]; - console.log(`🔑 URL elicitation demo: Finished sending API key elicitation request for session ${sessionId}`); - }) - .catch(error => { - console.error('Error sending API key elicitation:', error); - // Keep in map to potentially retry on next reconnect - }); - } -}; - -// Set up GET route with conditional auth middleware -app.get('/mcp', authMiddleware, mcpGetHandler); - -// Handle DELETE requests for session termination (according to MCP spec) -const mcpDeleteHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - console.log(`Received session termination request for session ${sessionId}`); - - try { - const transport = transports[sessionId]; - await transport.handleRequest(req, res); - } catch (error) { - console.error('Error handling session termination:', error); - if (!res.headersSent) { - res.status(500).send('Error processing session termination'); - } - } -}; - -// Set up DELETE route with auth middleware -app.delete('/mcp', authMiddleware, mcpDeleteHandler); - -app.listen(MCP_PORT, error => { - if (error) { - console.error('Failed to start server:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } - console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); - console.log(` Protected Resource Metadata: http://localhost:${MCP_PORT}/.well-known/oauth-protected-resource/mcp`); -}); - -// Handle server shutdown -process.on('SIGINT', async () => { - console.log('Shutting down server...'); - - // Close all active transports to properly clean up resources - for (const sessionId in transports) { - try { - console.log(`Closing transport for session ${sessionId}`); - await transports[sessionId]!.close(); - delete transports[sessionId]; - delete sessionsNeedingElicitation[sessionId]; - } catch (error) { - console.error(`Error closing transport for session ${sessionId}:`, error); - } - } - console.log('Server shutdown complete'); - process.exit(0); -}); diff --git a/examples/server/src/honoWebStandardStreamableHttp.ts b/examples/server/src/honoWebStandardStreamableHttp.ts deleted file mode 100644 index b15f9885fa..0000000000 --- a/examples/server/src/honoWebStandardStreamableHttp.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Example MCP server using Hono with WebStandardStreamableHTTPServerTransport - * - * This example demonstrates using the Web Standard transport directly with Hono, - * which works on any runtime: Node.js, Cloudflare Workers, Deno, Bun, etc. - * - * Run with: pnpm tsx src/honoWebStandardStreamableHttp.ts - */ - -import { serve } from '@hono/node-server'; -import type { CallToolResult } from '@modelcontextprotocol/server'; -import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; -import { Hono } from 'hono'; -import { cors } from 'hono/cors'; -import * as z from 'zod/v4'; - -// Create the MCP server -const server = new McpServer({ - name: 'hono-webstandard-mcp-server', - version: '1.0.0' -}); - -// Register a simple greeting tool -server.registerTool( - 'greet', - { - title: 'Greeting Tool', - description: 'A simple greeting tool', - inputSchema: z.object({ name: z.string().describe('Name to greet') }) - }, - async ({ name }): Promise => { - return { - content: [{ type: 'text', text: `Hello, ${name}! (from Hono + WebStandard transport)` }] - }; - } -); - -// Create a stateless transport (no options = no session management) -const transport = new WebStandardStreamableHTTPServerTransport(); - -// Create the Hono app -const app = new Hono(); - -// Enable CORS for all origins -app.use( - '*', - cors({ - origin: '*', - allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], - allowHeaders: ['Content-Type', 'mcp-session-id', 'Last-Event-ID', 'mcp-protocol-version'], - exposeHeaders: ['mcp-session-id', 'mcp-protocol-version'] - }) -); - -// Health check endpoint -app.get('/health', c => c.json({ status: 'ok' })); - -// MCP endpoint -app.all('/mcp', c => transport.handleRequest(c.req.raw)); - -// Start the server -const PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000; - -await server.connect(transport); - -console.log(`Starting Hono MCP server on port ${PORT}`); -console.log(`Health check: http://localhost:${PORT}/health`); -console.log(`MCP endpoint: http://localhost:${PORT}/mcp`); - -serve({ - fetch: app.fetch, - port: PORT -}); diff --git a/examples/server/src/jsonResponseStreamableHttp.ts b/examples/server/src/jsonResponseStreamableHttp.ts deleted file mode 100644 index 01759d6fc6..0000000000 --- a/examples/server/src/jsonResponseStreamableHttp.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -import { createMcpExpressApp } from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { CallToolResult } from '@modelcontextprotocol/server'; -import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; -import type { Request, Response } from 'express'; -import * as z from 'zod/v4'; - -// Create an MCP server with implementation details -const getServer = () => { - const server = new McpServer( - { - name: 'json-response-streamable-http-server', - version: '1.0.0' - }, - { - capabilities: { - logging: {} - } - } - ); - - // Register a simple tool that returns a greeting - server.registerTool( - 'greet', - { - description: 'A simple greeting tool', - inputSchema: z.object({ - name: z.string().describe('Name to greet') - }) - }, - async ({ name }): Promise => { - return { - content: [ - { - type: 'text', - text: `Hello, ${name}!` - } - ] - }; - } - ); - - // Register a tool that sends multiple greetings with notifications - server.registerTool( - 'multi-greet', - { - description: 'A tool that sends different greetings with delays between them', - inputSchema: z.object({ - name: z.string().describe('Name to greet') - }) - }, - async ({ name }, ctx): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - - await ctx.mcpReq.log('debug', `Starting multi-greet for ${name}`); - - await sleep(1000); // Wait 1 second before first greeting - - await ctx.mcpReq.log('info', `Sending first greeting to ${name}`); - - await sleep(1000); // Wait another second before second greeting - - await ctx.mcpReq.log('info', `Sending second greeting to ${name}`); - - return { - content: [ - { - type: 'text', - text: `Good morning, ${name}!` - } - ] - }; - } - ); - return server; -}; - -const app = createMcpExpressApp(); - -// Map to store transports by session ID -const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; - -app.post('/mcp', async (req: Request, res: Response) => { - console.log('Received MCP request:', req.body); - try { - // Check for existing session ID - const sessionId = req.headers['mcp-session-id'] as string | undefined; - let transport: NodeStreamableHTTPServerTransport; - - if (sessionId && transports[sessionId]) { - // Reuse existing transport - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - // New initialization request - use JSON response mode - transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - enableJsonResponse: true, // Enable JSON response mode - onsessioninitialized: sessionId => { - // Store the transport by session ID when session is initialized - // This avoids race conditions where requests might come in before the session is stored - console.log(`Session initialized with ID: ${sessionId}`); - transports[sessionId] = transport; - } - }); - - // Connect the transport to the MCP server BEFORE handling the request - const server = getServer(); - await server.connect(transport); - await transport.handleRequest(req, res, req.body); - return; // Already handled - } else if (sessionId) { - res.status(404).json({ - jsonrpc: '2.0', - error: { code: -32_001, message: 'Session not found' }, - id: null - }); - return; - } else { - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32_000, message: 'Bad Request: Session ID required' }, - id: null - }); - return; - } - - // Handle the request with existing transport - no need to reconnect - await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32_603, - message: 'Internal server error' - }, - id: null - }); - } - } -}); - -// Handle GET requests for SSE streams according to spec -app.get('/mcp', async (req: Request, res: Response) => { - // Since this is a very simple example, we don't support GET requests for this server - // The spec requires returning 405 Method Not Allowed in this case - res.status(405).set('Allow', 'POST').send('Method Not Allowed'); -}); - -// Start the server -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(`MCP Streamable HTTP Server listening on port ${PORT}`); -}); - -// Handle server shutdown -process.on('SIGINT', async () => { - console.log('Shutting down server...'); - process.exit(0); -}); diff --git a/examples/server/src/mcpServerOutputSchema.ts b/examples/server/src/mcpServerOutputSchema.ts deleted file mode 100644 index 955855c419..0000000000 --- a/examples/server/src/mcpServerOutputSchema.ts +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env node -/** - * Example MCP server using the high-level McpServer API with outputSchema - * This demonstrates how to easily create tools with structured output - */ - -import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import * as z from 'zod/v4'; - -const server = new McpServer({ - name: 'mcp-output-schema-high-level-example', - version: '1.0.0' -}); - -// Define a tool with structured output - Weather data -server.registerTool( - 'get_weather', - { - description: 'Get weather information for a city', - inputSchema: z.object({ - city: z.string().describe('City name'), - country: z.string().describe('Country code (e.g., US, UK)') - }), - outputSchema: z.object({ - temperature: z.object({ - celsius: z.number(), - fahrenheit: z.number() - }), - conditions: z.enum(['sunny', 'cloudy', 'rainy', 'stormy', 'snowy']), - humidity: z.number().min(0).max(100), - wind: z.object({ - speed_kmh: z.number(), - direction: z.string() - }) - }) - }, - async ({ city, country }) => { - // Parameters are available but not used in this example - void city; - void country; - // Simulate weather API call - const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10; - const conditions = ['sunny', 'cloudy', 'rainy', 'stormy', 'snowy'][Math.floor(Math.random() * 5)]; - - const structuredContent = { - temperature: { - celsius: temp_c, - fahrenheit: Math.round(((temp_c * 9) / 5 + 32) * 10) / 10 - }, - conditions, - humidity: Math.round(Math.random() * 100), - wind: { - speed_kmh: Math.round(Math.random() * 50), - direction: ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'][Math.floor(Math.random() * 8)] - } - }; - - return { - content: [ - { - type: 'text', - text: JSON.stringify(structuredContent, null, 2) - } - ], - structuredContent - }; - } -); - -async function main() { - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error('High-level Output Schema Example Server running on stdio'); -} - -try { - await main(); -} catch (error) { - console.error('Server error:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/server/src/resourceServerOnly.ts b/examples/server/src/resourceServerOnly.ts deleted file mode 100644 index 1a1708177e..0000000000 --- a/examples/server/src/resourceServerOnly.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Minimal Resource-Server-only auth using the SDK's RS helpers - * (`mcpAuthMetadataRouter`, `requireBearerAuth`, `OAuthTokenVerifier`). - * - * No better-auth. The Authorization Server is external; this example points - * its metadata at a placeholder issuer. For a full AS+RS setup with a real - * demo Authorization Server, see {@link ./simpleStreamableHttp.ts}. - * - * Run: pnpm tsx src/resourceServerOnly.ts - * Probe: curl http://localhost:3000/.well-known/oauth-protected-resource/mcp - * curl -H 'Authorization: Bearer demo-token' -X POST http://localhost:3000/mcp ... - */ - -import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; -import { - createMcpExpressApp, - getOAuthProtectedResourceMetadataUrl, - mcpAuthMetadataRouter, - requireBearerAuth -} from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { AuthInfo, CallToolResult, OAuthMetadata } from '@modelcontextprotocol/server'; -import { McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; -import type { Request, Response } from 'express'; -import * as z from 'zod/v4'; - -const PORT = 3000; -const mcpServerUrl = new URL(`http://localhost:${PORT}/mcp`); - -// In a real deployment this is your external Authorization Server's metadata -// (RFC 8414). The SDK router serves it verbatim at -// /.well-known/oauth-authorization-server so clients probing the RS origin -// can still discover the AS. -const oauthMetadata: OAuthMetadata = { - issuer: 'https://auth.example.com', - authorization_endpoint: 'https://auth.example.com/authorize', - token_endpoint: 'https://auth.example.com/token', - response_types_supported: ['code'] -}; - -// Replace with JWT verification, RFC 7662 introspection, etc. -const staticTokenVerifier: OAuthTokenVerifier = { - async verifyAccessToken(token): Promise { - if (token !== 'demo-token') { - throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token'); - } - return { token, clientId: 'demo-client', scopes: ['mcp'], expiresAt: Math.floor(Date.now() / 1000) + 3600 }; - } -}; - -const server = new McpServer({ name: 'rs-only', version: '1.0.0' }, { capabilities: {} }); -server.registerTool( - 'whoami', - { description: 'Returns the authenticated subject.', inputSchema: z.object({}) }, - async (_args, ctx): Promise => ({ - content: [{ type: 'text', text: `client=${ctx.http?.authInfo?.clientId ?? 'anon'}` }] - }) -); - -const app = createMcpExpressApp(); - -app.use( - mcpAuthMetadataRouter({ - oauthMetadata, - resourceServerUrl: mcpServerUrl, - resourceName: 'RS-only example' - }) -); - -const auth = requireBearerAuth({ - verifier: staticTokenVerifier, - requiredScopes: ['mcp'], - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) -}); - -app.post('/mcp', auth, async (req: Request, res: Response) => { - const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); - res.on('close', () => void transport.close()); - await server.connect(transport); - await transport.handleRequest(req, res, req.body); -}); - -app.listen(PORT, () => { - console.log(`RS-only MCP server on http://localhost:${PORT}/mcp`); - console.log(` PRM: ${getOAuthProtectedResourceMetadataUrl(mcpServerUrl)}`); - console.log(` AS metadata mirror: http://localhost:${PORT}/.well-known/oauth-authorization-server`); -}); diff --git a/examples/server/src/simpleStatelessStreamableHttp.ts b/examples/server/src/simpleStatelessStreamableHttp.ts deleted file mode 100644 index 2b4f0363d8..0000000000 --- a/examples/server/src/simpleStatelessStreamableHttp.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { createMcpExpressApp } from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { CallToolResult, GetPromptResult, ReadResourceResult } from '@modelcontextprotocol/server'; -import { McpServer } from '@modelcontextprotocol/server'; -import type { Request, Response } from 'express'; -import * as z from 'zod/v4'; - -const getServer = () => { - // Create an MCP server with implementation details - const server = new McpServer( - { - name: 'stateless-streamable-http-server', - version: '1.0.0' - }, - { capabilities: { logging: {} } } - ); - - // Register a simple prompt - server.registerPrompt( - 'greeting-template', - { - description: 'A simple greeting prompt template', - argsSchema: z.object({ - name: z.string().describe('Name to include in greeting') - }) - }, - async ({ name }): Promise => { - return { - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Please greet ${name} in a friendly manner.` - } - } - ] - }; - } - ); - - // Register a tool specifically for testing resumability - server.registerTool( - 'start-notification-stream', - { - description: 'Starts sending periodic notifications for testing resumability', - inputSchema: z.object({ - interval: z.number().describe('Interval in milliseconds between notifications').default(100), - count: z.number().describe('Number of notifications to send (0 for 100)').default(10) - }) - }, - async ({ interval, count }, ctx): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - let counter = 0; - - while (count === 0 || counter < count) { - counter++; - try { - await ctx.mcpReq.log('info', `Periodic notification #${counter} at ${new Date().toISOString()}`); - } catch (error) { - console.error('Error sending notification:', error); - } - // Wait for the specified interval - await sleep(interval); - } - - return { - content: [ - { - type: 'text', - text: `Started sending periodic notifications every ${interval}ms` - } - ] - }; - } - ); - - // Create a simple resource at a fixed URI - server.registerResource( - 'greeting-resource', - 'https://example.com/greetings/default', - { mimeType: 'text/plain' }, - async (): Promise => { - return { - contents: [ - { - uri: 'https://example.com/greetings/default', - text: 'Hello, world!' - } - ] - }; - } - ); - return server; -}; - -const app = createMcpExpressApp(); - -app.post('/mcp', async (req: Request, res: Response) => { - const server = getServer(); - try { - const transport: NodeStreamableHTTPServerTransport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: undefined - }); - await server.connect(transport); - await transport.handleRequest(req, res, req.body); - res.on('close', () => { - console.log('Request closed'); - transport.close(); - server.close(); - }); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32_603, - message: 'Internal server error' - }, - id: null - }); - } - } -}); - -app.get('/mcp', async (req: Request, res: Response) => { - console.log('Received GET MCP request'); - res.writeHead(405).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32_000, - message: 'Method not allowed.' - }, - id: null - }) - ); -}); - -app.delete('/mcp', async (req: Request, res: Response) => { - console.log('Received DELETE MCP request'); - res.writeHead(405).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32_000, - message: 'Method not allowed.' - }, - id: null - }) - ); -}); - -// Start the server -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(`MCP Stateless Streamable HTTP Server listening on port ${PORT}`); -}); - -// Handle server shutdown -process.on('SIGINT', async () => { - console.log('Shutting down server...'); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(0); -}); diff --git a/examples/server/src/simpleStreamableHttp.ts b/examples/server/src/simpleStreamableHttp.ts deleted file mode 100644 index d034a48d83..0000000000 --- a/examples/server/src/simpleStreamableHttp.ts +++ /dev/null @@ -1,660 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@modelcontextprotocol/examples-shared'; -import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { - CallToolResult, - GetPromptResult, - PrimitiveSchemaDefinition, - ReadResourceResult, - ResourceLink -} from '@modelcontextprotocol/server'; -import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; -import cors from 'cors'; -import type { Request, Response } from 'express'; -import * as z from 'zod/v4'; - -import { InMemoryEventStore } from './inMemoryEventStore.js'; - -// Check for OAuth flag -const useOAuth = process.argv.includes('--oauth'); -const dangerousLoggingEnabled = process.argv.includes('--dangerous-logging-enabled'); - -// Create an MCP server with implementation details -const getServer = () => { - const server = new McpServer( - { - name: 'simple-streamable-http-server', - version: '1.0.0', - icons: [{ src: './mcp.svg', sizes: ['512x512'], mimeType: 'image/svg+xml' }], - websiteUrl: 'https://github.com/modelcontextprotocol/typescript-sdk' - }, - { - capabilities: { - logging: {} - } - } - ); - - // Register a simple tool that returns a greeting - server.registerTool( - 'greet', - { - title: 'Greeting Tool', // Display name for UI - description: 'A simple greeting tool', - // Optional icons a client can render in its UI for this tool. - icons: [{ src: './greet.svg', sizes: ['48x48'], mimeType: 'image/svg+xml' }], - inputSchema: z.object({ - name: z.string().describe('Name to greet') - }) - }, - async ({ name }): Promise => { - return { - content: [ - { - type: 'text', - text: `Hello, ${name}!` - } - ] - }; - } - ); - - // Register a tool that sends multiple greetings with notifications (with annotations) - server.registerTool( - 'multi-greet', - { - description: 'A tool that sends different greetings with delays between them', - inputSchema: z.object({ - name: z.string().describe('Name to greet') - }), - annotations: { - title: 'Multiple Greeting Tool', - readOnlyHint: true, - openWorldHint: false - } - }, - async ({ name }, ctx): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - - await ctx.mcpReq.log('debug', `Starting multi-greet for ${name}`); - - await sleep(1000); // Wait 1 second before first greeting - - await ctx.mcpReq.log('info', `Sending first greeting to ${name}`); - - await sleep(1000); // Wait another second before second greeting - - await ctx.mcpReq.log('info', `Sending second greeting to ${name}`); - - return { - content: [ - { - type: 'text', - text: `Good morning, ${name}!` - } - ] - }; - } - ); - // Register a tool that demonstrates form elicitation (user input collection with a schema) - // This creates a closure that captures the server instance - server.registerTool( - 'collect-user-info', - { - description: 'A tool that collects user information through form elicitation', - inputSchema: z.object({ - infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect') - }) - }, - async ({ infoType }, ctx): Promise => { - let message: string; - let requestedSchema: { - type: 'object'; - properties: Record; - required?: string[]; - }; - - switch (infoType) { - case 'contact': { - message = 'Please provide your contact information'; - requestedSchema = { - type: 'object', - properties: { - name: { - type: 'string', - title: 'Full Name', - description: 'Your full name' - }, - email: { - type: 'string', - title: 'Email Address', - description: 'Your email address', - format: 'email' - }, - phone: { - type: 'string', - title: 'Phone Number', - description: 'Your phone number (optional)' - } - }, - required: ['name', 'email'] - }; - break; - } - case 'preferences': { - message = 'Please set your preferences'; - requestedSchema = { - type: 'object', - properties: { - theme: { - type: 'string', - title: 'Theme', - description: 'Choose your preferred theme', - enum: ['light', 'dark', 'auto'], - enumNames: ['Light', 'Dark', 'Auto'] - }, - notifications: { - type: 'boolean', - title: 'Enable Notifications', - description: 'Would you like to receive notifications?', - default: true - }, - frequency: { - type: 'string', - title: 'Notification Frequency', - description: 'How often would you like notifications?', - enum: ['daily', 'weekly', 'monthly'], - enumNames: ['Daily', 'Weekly', 'Monthly'] - } - }, - required: ['theme'] - }; - break; - } - case 'feedback': { - message = 'Please provide your feedback'; - requestedSchema = { - type: 'object', - properties: { - rating: { - type: 'integer', - title: 'Rating', - description: 'Rate your experience (1-5)', - minimum: 1, - maximum: 5 - }, - comments: { - type: 'string', - title: 'Comments', - description: 'Additional comments (optional)', - maxLength: 500 - }, - recommend: { - type: 'boolean', - title: 'Would you recommend this?', - description: 'Would you recommend this to others?' - } - }, - required: ['rating', 'recommend'] - }; - break; - } - default: { - throw new Error(`Unknown info type: ${infoType}`); - } - } - - try { - // Use sendRequest through the ctx parameter to elicit input - const result = await ctx.mcpReq.send({ - method: 'elicitation/create', - params: { - mode: 'form', - message, - requestedSchema - } - }); - - if (result.action === 'accept') { - return { - content: [ - { - type: 'text', - text: `Thank you! Collected ${infoType} information: ${JSON.stringify(result.content, null, 2)}` - } - ] - }; - } else if (result.action === 'decline') { - return { - content: [ - { - type: 'text', - text: `No information was collected. User declined ${infoType} information request.` - } - ] - }; - } else { - return { - content: [ - { - type: 'text', - text: `Information collection was cancelled by the user.` - } - ] - }; - } - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Error collecting ${infoType} information: ${error}` - } - ] - }; - } - } - ); - - // Register a simple prompt with title - server.registerPrompt( - 'greeting-template', - { - title: 'Greeting Template', // Display name for UI - description: 'A simple greeting prompt template', - argsSchema: z.object({ - name: z.string().describe('Name to include in greeting') - }) - }, - async ({ name }): Promise => { - return { - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Please greet ${name} in a friendly manner.` - } - } - ] - }; - } - ); - - // Register a tool specifically for testing resumability - server.registerTool( - 'start-notification-stream', - { - description: 'Starts sending periodic notifications for testing resumability', - inputSchema: z.object({ - interval: z.number().describe('Interval in milliseconds between notifications').default(100), - count: z.number().describe('Number of notifications to send (0 for 100)').default(50) - }) - }, - async ({ interval, count }, ctx): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - let counter = 0; - - while (count === 0 || counter < count) { - counter++; - try { - await ctx.mcpReq.log('info', `Periodic notification #${counter} at ${new Date().toISOString()}`); - } catch (error) { - console.error('Error sending notification:', error); - } - // Wait for the specified interval - await sleep(interval); - } - - return { - content: [ - { - type: 'text', - text: `Started sending periodic notifications every ${interval}ms` - } - ] - }; - } - ); - - // Create a simple resource at a fixed URI - server.registerResource( - 'greeting-resource', - 'https://example.com/greetings/default', - { - title: 'Default Greeting', // Display name for UI - description: 'A simple greeting resource', - mimeType: 'text/plain' - }, - async (): Promise => { - return { - contents: [ - { - uri: 'https://example.com/greetings/default', - text: 'Hello, world!' - } - ] - }; - } - ); - - // Create additional resources for ResourceLink demonstration - server.registerResource( - 'example-file-1', - 'file:///example/file1.txt', - { - title: 'Example File 1', - description: 'First example file for ResourceLink demonstration', - mimeType: 'text/plain' - }, - async (): Promise => { - return { - contents: [ - { - uri: 'file:///example/file1.txt', - text: 'This is the content of file 1' - } - ] - }; - } - ); - - server.registerResource( - 'example-file-2', - 'file:///example/file2.txt', - { - title: 'Example File 2', - description: 'Second example file for ResourceLink demonstration', - mimeType: 'text/plain' - }, - async (): Promise => { - return { - contents: [ - { - uri: 'file:///example/file2.txt', - text: 'This is the content of file 2' - } - ] - }; - } - ); - - // Register a tool that returns ResourceLinks - server.registerTool( - 'list-files', - { - title: 'List Files with ResourceLinks', - description: 'Returns a list of files as ResourceLinks without embedding their content', - inputSchema: z.object({ - includeDescriptions: z.boolean().optional().describe('Whether to include descriptions in the resource links') - }) - }, - async ({ includeDescriptions = true }): Promise => { - const resourceLinks: ResourceLink[] = [ - { - type: 'resource_link', - uri: 'https://example.com/greetings/default', - name: 'Default Greeting', - mimeType: 'text/plain', - ...(includeDescriptions && { description: 'A simple greeting resource' }) - }, - { - type: 'resource_link', - uri: 'file:///example/file1.txt', - name: 'Example File 1', - mimeType: 'text/plain', - ...(includeDescriptions && { description: 'First example file for ResourceLink demonstration' }) - }, - { - type: 'resource_link', - uri: 'file:///example/file2.txt', - name: 'Example File 2', - mimeType: 'text/plain', - ...(includeDescriptions && { description: 'Second example file for ResourceLink demonstration' }) - } - ]; - - return { - content: [ - { - type: 'text', - text: 'Here are the available files as resource links:' - }, - ...resourceLinks, - { - type: 'text', - text: '\nYou can read any of these resources using their URI.' - } - ] - }; - } - ); - - return server; -}; - -const MCP_PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000; -const AUTH_PORT = process.env.MCP_AUTH_PORT ? Number.parseInt(process.env.MCP_AUTH_PORT, 10) : 3001; - -const app = createMcpExpressApp(); - -// Enable CORS for browser-based clients (demo only) -// This allows cross-origin requests and exposes WWW-Authenticate header for OAuth -// WARNING: This configuration is for demo purposes only. In production, you should restrict this to specific origins and configure CORS yourself. -app.use( - cors({ - exposedHeaders: ['WWW-Authenticate', 'Mcp-Session-Id', 'Last-Event-Id', 'Mcp-Protocol-Version'], - origin: '*' // WARNING: This allows all origins to access the MCP server. In production, you should restrict this to specific origins. - }) -); - -// Set up OAuth if enabled -let authMiddleware = null; -if (useOAuth) { - // Create auth middleware for MCP endpoints - const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`); - const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); - - setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true, dangerousLoggingEnabled }); - - // Add protected resource metadata route to the MCP server - // This allows clients to discover the auth server - // Pass the resource path so metadata is served at /.well-known/oauth-protected-resource/mcp - app.use(createProtectedResourceMetadataRouter('/mcp')); - - authMiddleware = requireBearerAuth({ - verifier: demoTokenVerifier, - requiredScopes: [], - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) - }); -} - -// Map to store transports by session ID -const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; - -// MCP POST endpoint with optional auth -const mcpPostHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (sessionId) { - console.log(`Received MCP request for session: ${sessionId}`); - } else { - console.log('Request body:', req.body); - } - - if (useOAuth && req.auth) { - console.log('Authenticated user:', req.auth); - } - try { - let transport: NodeStreamableHTTPServerTransport; - if (sessionId && transports[sessionId]) { - // Reuse existing transport - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - // New initialization request - const eventStore = new InMemoryEventStore(); - transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - eventStore, // Enable resumability - onsessioninitialized: sessionId => { - // Store the transport by session ID when session is initialized - // This avoids race conditions where requests might come in before the session is stored - console.log(`Session initialized with ID: ${sessionId}`); - transports[sessionId] = transport; - } - }); - - // Set up onclose handler to clean up transport when closed - transport.onclose = () => { - const sid = transport.sessionId; - if (sid && transports[sid]) { - console.log(`Transport closed for session ${sid}, removing from transports map`); - delete transports[sid]; - } - }; - - // Connect the transport to the MCP server BEFORE handling the request - // so responses can flow back through the same transport - const server = getServer(); - await server.connect(transport); - - await transport.handleRequest(req, res, req.body); - return; // Already handled - } else if (sessionId) { - res.status(404).json({ - jsonrpc: '2.0', - error: { code: -32_001, message: 'Session not found' }, - id: null - }); - return; - } else { - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32_000, message: 'Bad Request: Session ID required' }, - id: null - }); - return; - } - - // Handle the request with existing transport - no need to reconnect - // The existing transport is already connected to the server - await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32_603, - message: 'Internal server error' - }, - id: null - }); - } - } -}; - -// Set up routes with conditional auth middleware -if (useOAuth && authMiddleware) { - app.post('/mcp', authMiddleware, mcpPostHandler); -} else { - app.post('/mcp', mcpPostHandler); -} - -// Handle GET requests for SSE streams (using built-in support from StreamableHTTP) -const mcpGetHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - if (useOAuth && req.auth) { - console.log('Authenticated SSE connection from user:', req.auth); - } - - // Check for Last-Event-ID header for resumability - const lastEventId = req.headers['last-event-id'] as string | undefined; - if (lastEventId) { - console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); - } else { - console.log(`Establishing new SSE stream for session ${sessionId}`); - } - - const transport = transports[sessionId]; - await transport.handleRequest(req, res); -}; - -// Set up GET route with conditional auth middleware -if (useOAuth && authMiddleware) { - app.get('/mcp', authMiddleware, mcpGetHandler); -} else { - app.get('/mcp', mcpGetHandler); -} - -// Handle DELETE requests for session termination (according to MCP spec) -const mcpDeleteHandler = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - console.log(`Received session termination request for session ${sessionId}`); - - try { - const transport = transports[sessionId]; - await transport.handleRequest(req, res); - } catch (error) { - console.error('Error handling session termination:', error); - if (!res.headersSent) { - res.status(500).send('Error processing session termination'); - } - } -}; - -// Set up DELETE route with conditional auth middleware -if (useOAuth && authMiddleware) { - app.delete('/mcp', authMiddleware, mcpDeleteHandler); -} else { - app.delete('/mcp', mcpDeleteHandler); -} - -app.listen(MCP_PORT, error => { - if (error) { - console.error('Failed to start server:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); - } - console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); - if (useOAuth) { - console.log(` Protected Resource Metadata: http://localhost:${MCP_PORT}/.well-known/oauth-protected-resource/mcp`); - } -}); - -// Handle server shutdown -process.on('SIGINT', async () => { - console.log('Shutting down server...'); - - // Close all active transports to properly clean up resources - for (const sessionId in transports) { - try { - console.log(`Closing transport for session ${sessionId}`); - await transports[sessionId]!.close(); - delete transports[sessionId]; - } catch (error) { - console.error(`Error closing transport for session ${sessionId}:`, error); - } - } - console.log('Server shutdown complete'); - process.exit(0); -}); diff --git a/examples/server/src/standaloneSseWithGetStreamableHttp.ts b/examples/server/src/standaloneSseWithGetStreamableHttp.ts deleted file mode 100644 index 7e133f6d2e..0000000000 --- a/examples/server/src/standaloneSseWithGetStreamableHttp.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -import { createMcpExpressApp } from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { ReadResourceResult } from '@modelcontextprotocol/server'; -import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; -import type { Request, Response } from 'express'; - -// Helper to register a dynamic resource on a given server instance -const addResource = (server: McpServer, name: string, content: string) => { - const uri = `https://mcp-example.com/dynamic/${encodeURIComponent(name)}`; - server.registerResource( - name, - uri, - { mimeType: 'text/plain', description: `Dynamic resource: ${name}` }, - async (): Promise => { - return { - contents: [{ uri, text: content }] - }; - } - ); -}; - -// Create a fresh MCP server per client connection to avoid shared state between clients -const getServer = () => { - const server = new McpServer({ - name: 'resource-list-changed-notification-server', - version: '1.0.0' - }); - - addResource(server, 'example-resource', 'Initial content for example-resource'); - - return server; -}; - -// Store transports and their associated servers by session ID -const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; -const servers: { [sessionId: string]: McpServer } = {}; - -// Periodically add a new resource to all active server instances for testing -const resourceChangeInterval = setInterval(() => { - const name = randomUUID(); - for (const sessionId in servers) { - addResource(servers[sessionId]!, name, `Content for ${name}`); - } -}, 5000); // Change resources every 5 seconds for testing - -const app = createMcpExpressApp(); - -app.post('/mcp', async (req: Request, res: Response) => { - console.log('Received MCP request:', req.body); - try { - // Check for existing session ID - const sessionId = req.headers['mcp-session-id'] as string | undefined; - let transport: NodeStreamableHTTPServerTransport; - - if (sessionId && transports[sessionId]) { - // Reuse existing transport - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - // New initialization request - create a fresh server for this client - const server = getServer(); - transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: sessionId => { - // Store the transport and server by session ID when session is initialized - // This avoids race conditions where requests might come in before the session is stored - console.log(`Session initialized with ID: ${sessionId}`); - transports[sessionId] = transport; - servers[sessionId] = server; - } - }); - - // Clean up both maps when the transport closes - transport.onclose = () => { - const sid = transport.sessionId; - if (sid) { - delete transports[sid]; - delete servers[sid]; - } - }; - - // Connect the fresh MCP server to the transport - await server.connect(transport); - - // Handle the request - the onsessioninitialized callback will store the transport - await transport.handleRequest(req, res, req.body); - return; // Already handled - } else if (sessionId) { - res.status(404).json({ - jsonrpc: '2.0', - error: { code: -32_001, message: 'Session not found' }, - id: null - }); - return; - } else { - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32_000, message: 'Bad Request: Session ID required' }, - id: null - }); - return; - } - - // Handle the request with existing transport - await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32_603, - message: 'Internal server error' - }, - id: null - }); - } - } -}); - -// Handle GET requests for SSE streams (now using built-in support from StreamableHTTP) -app.get('/mcp', async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - console.log(`Establishing SSE stream for session ${sessionId}`); - const transport = transports[sessionId]; - await transport.handleRequest(req, res); -}); - -// Start the server -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(`Server listening on port ${PORT}`); -}); - -// Handle server shutdown -process.on('SIGINT', async () => { - console.log('Shutting down server...'); - clearInterval(resourceChangeInterval); - - // Close all active transports to properly clean up resources - for (const sessionId in transports) { - try { - console.log(`Closing transport for session ${sessionId}`); - await transports[sessionId]!.close(); - delete transports[sessionId]; - delete servers[sessionId]; - } catch (error) { - console.error(`Error closing transport for session ${sessionId}:`, error); - } - } - console.log('Server shutdown complete'); - process.exit(0); -}); diff --git a/examples/server/src/toolWithSampleServer.ts b/examples/server/src/toolWithSampleServer.ts deleted file mode 100644 index f6b053cf24..0000000000 --- a/examples/server/src/toolWithSampleServer.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Run with: pnpm tsx src/toolWithSampleServer.ts - -import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import * as z from 'zod/v4'; - -const mcpServer = new McpServer({ - name: 'tools-with-sample-server', - version: '1.0.0' -}); - -// Tool that uses LLM sampling to summarize any text -mcpServer.registerTool( - 'summarize', - { - description: 'Summarize any text using an LLM', - inputSchema: z.object({ - text: z.string().describe('Text to summarize') - }) - }, - async ({ text }) => { - // Call the LLM through MCP sampling - const response = await mcpServer.server.createMessage({ - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Please summarize the following text concisely:\n\n${text}` - } - } - ], - maxTokens: 500 - }); - - // Since we're not passing tools param to createMessage, response.content is single content - return { - content: [ - { - type: 'text', - text: response.content.type === 'text' ? response.content.text : 'Unable to generate summary' - } - ] - }; - } -); - -async function main() { - const transport = new StdioServerTransport(); - await mcpServer.connect(transport); - console.log('MCP server is running...'); -} - -try { - await main(); -} catch (error) { - console.error('Server error:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/server/src/valibotExample.ts b/examples/server/src/valibotExample.ts deleted file mode 100644 index 8d92bf1993..0000000000 --- a/examples/server/src/valibotExample.ts +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env node -/** - * Minimal MCP server using Valibot for schema validation. - * Use toStandardJsonSchema() from @valibot/to-json-schema to create - * StandardJSONSchemaV1-compliant schemas. - */ - -import { McpServer } from '@modelcontextprotocol/server'; -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import { toStandardJsonSchema } from '@valibot/to-json-schema'; -import * as v from 'valibot'; - -const server = new McpServer({ - name: 'valibot-example', - version: '1.0.0' -}); - -// Register a tool with Valibot schema -server.registerTool( - 'greet', - { - description: 'Generate a greeting', - inputSchema: toStandardJsonSchema(v.object({ name: v.string() })) - }, - async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) -); - -const transport = new StdioServerTransport(); -await server.connect(transport); diff --git a/examples/server/tsdown.config.ts b/examples/server/tsdown.config.ts deleted file mode 100644 index efc4299d35..0000000000 --- a/examples/server/tsdown.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { defineConfig } from 'tsdown'; - -export default defineConfig({ - // 1. Entry Points - // Directly matches package.json include/exclude globs - entry: ['src/**/*.ts'], - - // 2. Output Configuration - format: ['esm'], - outDir: 'dist', - clean: true, // Recommended: Cleans 'dist' before building - sourcemap: true, - - // 3. Platform & Target - target: 'esnext', - platform: 'node', - shims: true, // Polyfills common Node.js shims (__dirname, etc.) - - // 4. Type Definitions - // Bundles d.ts files into a single output - dts: false, - // 5. Vendoring Strategy - Bundle the code for this specific package into the output, - // but treat all other dependencies as external (require/import). - noExternal: ['@modelcontextprotocol/examples-shared'] -}); diff --git a/examples/server/vitest.config.js b/examples/server/vitest.config.js deleted file mode 100644 index 496fca3200..0000000000 --- a/examples/server/vitest.config.js +++ /dev/null @@ -1,3 +0,0 @@ -import baseConfig from '@modelcontextprotocol/vitest-config'; - -export default baseConfig; diff --git a/examples/shared/eslint.config.mjs b/examples/shared/eslint.config.mjs index 83b79879f6..03c6acb755 100644 --- a/examples/shared/eslint.config.mjs +++ b/examples/shared/eslint.config.mjs @@ -8,7 +8,20 @@ export default [ files: ['src/**/*.{ts,tsx,js,jsx,mts,cts}'], rules: { // Allow console statements in examples only - 'no-console': 'off' + 'no-console': 'off', + // One-way dependency: @mcp-examples/shared is scaffolding consumed BY + // stories; it must never import FROM a story package. + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@mcp-examples/*', '!@mcp-examples/shared', '../../*/**'], + message: '@mcp-examples/shared must not import from story packages (one-way dependency).' + } + ] + } + ] } } ]; diff --git a/examples/shared/package.json b/examples/shared/package.json index 0bab8be920..7ecfc11157 100644 --- a/examples/shared/package.json +++ b/examples/shared/package.json @@ -1,5 +1,5 @@ { - "name": "@modelcontextprotocol/examples-shared", + "name": "@mcp-examples/shared", "private": true, "version": "2.0.0-alpha.0", "description": "Model Context Protocol implementation for TypeScript", @@ -8,6 +8,10 @@ "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", "type": "module", + "exports": { + ".": "./src/index.ts", + "./auth": "./src/indexAuth.ts" + }, "repository": { "type": "git", "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" @@ -21,15 +25,11 @@ ], "scripts": { "typecheck": "tsgo -p tsconfig.json --noEmit", - "prepack": "pnpm run build:esm && pnpm run build:cjs", "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", "check": "pnpm run typecheck && pnpm run lint", "test": "vitest run", - "test:watch": "vitest", - "start": "pnpm run server", - "server": "tsx watch --clear-screen=false scripts/cli.ts server", - "client": "tsx scripts/cli.ts client" + "test:watch": "vitest" }, "dependencies": { "@modelcontextprotocol/core": "workspace:^", diff --git a/examples/shared/src/args.ts b/examples/shared/src/args.ts new file mode 100644 index 0000000000..65045fc400 --- /dev/null +++ b/examples/shared/src/args.ts @@ -0,0 +1,61 @@ +/** + * Argv-parsing scaffold shared by every `examples//` pair. + * + * Intentionally **zero SDK API calls** in this module — it is pure + * `process.argv` plumbing plus an assert wrapper. Each story's + * `server.ts`/`client.ts` shows the real `@modelcontextprotocol/*` calls + * inline (the canonical shape; see `examples/CONTRIBUTING.md`). This module + * only DRYs the parts a reader is not here to learn: flag parsing and + * sibling-path resolution. + * + * Re-exported `check` is `node:assert/strict` for readable inline assertions. + */ + +import { fileURLToPath } from 'node:url'; + +export { strict as check } from 'node:assert'; + +/** + * Resolve a sibling of the calling module to an absolute filesystem path + * (`fileURLToPath` handles Windows drive letters and percent-encoded segments, + * which `new URL(...).pathname` does not). Used by every story's stdio leg to + * spawn its companion `server.ts` — the path-resolution part of that line is + * scaffolding, so it lives here rather than being repeated in each `client.ts`. + */ +export function siblingPath(importMetaUrl: string | URL, name: string): string { + return fileURLToPath(new URL(name, importMetaUrl)); +} + +export type ExampleTransport = 'stdio' | 'http'; +export type ExampleEra = 'modern' | 'legacy'; + +export interface ExampleArgs { + /** `'http'` under `--http`, otherwise `'stdio'`. */ + transport: ExampleTransport; + /** `--port ` (or `$PORT`, or 3000) — meaningful on the server side. */ + port: number; + /** `--http ` (or `http://127.0.0.1:/mcp`) — meaningful on the client side. */ + url: string; + /** `'legacy'` under `--legacy`, otherwise `'modern'` (negotiates 2026-07-28). */ + era: ExampleEra; +} + +/** + * Parse `process.argv` into the four knobs every example branches on. + * + * The example runner (`scripts/examples/run-examples.ts`) drives the same + * binary over each transport/era combination by passing `--http`, `--port`, + * `--http ` and `--legacy`; manual runs use the same flags. + */ +export function parseExampleArgs(defaultPort = 3000): ExampleArgs { + const argv = process.argv.slice(2); + const transport: ExampleTransport = argv.includes('--http') ? 'http' : 'stdio'; + const era: ExampleEra = argv.includes('--legacy') ? 'legacy' : 'modern'; + const portIdx = argv.indexOf('--port'); + const port = portIdx === -1 ? Number(process.env.PORT ?? defaultPort) : Number(argv[portIdx + 1]); + const httpIdx = argv.indexOf('--http'); + // A bare `argv[indexOf('--http') + 1]` reads `argv[0]` (the script path) + // when the flag is absent, so guard with `httpIdx === -1` first. + const url = httpIdx === -1 ? `http://127.0.0.1:${port}/mcp` : (argv[httpIdx + 1] ?? `http://127.0.0.1:${port}/mcp`); + return { transport, port, url, era }; +} diff --git a/examples/shared/src/authServer.ts b/examples/shared/src/authServer.ts index 995fedc7d9..dc7d602cee 100644 --- a/examples/shared/src/authServer.ts +++ b/examples/shared/src/authServer.ts @@ -15,7 +15,7 @@ import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; import { toNodeHandler } from 'better-auth/node'; import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from 'better-auth/plugins'; import cors from 'cors'; -import type { Request, Response as ExpressResponse, Router } from 'express'; +import type { NextFunction, Request, Response as ExpressResponse, Router } from 'express'; import express from 'express'; import type { DemoAuth } from './auth.js'; @@ -34,6 +34,22 @@ export interface SetupAuthServerOptions { * Only use for debugging purposes. */ dangerousLoggingEnabled?: boolean; + /** + * DEMO ONLY. When `true`, the `/api/auth/mcp/authorize` endpoint skips the + * consent screen entirely and immediately 302s back to the client's + * `redirect_uri` with an authorization `code` — exactly what would happen + * after a real user clicked **Approve**. Mechanically this strips the OIDC + * `prompt` parameter from the request before it reaches better-auth, so the + * MCP plugin's authorize handler takes its no-consent fast path. Combined + * with the `/sign-in` page that auto-signs-in the demo user, the entire + * authorization-code flow becomes a deterministic chain of 302s a headless + * client can follow with `fetch(..., { redirect: 'manual' })`. + * + * The `examples/oauth/` server enables this when + * `OAUTH_DEMO_AUTO_CONSENT=1` so the CI client (`client.ts`) can drive the + * full browser flow without a browser. NEVER enable in production. + */ + autoConsent?: boolean; } // Store auth instance globally so it can be used for token verification @@ -88,7 +104,7 @@ async function ensureDemoUserExists(auth: DemoAuth): Promise { * @param options - Server configuration */ export function setupAuthServer(options: SetupAuthServerOptions): void { - const { authServerUrl, mcpServerUrl, demoMode, dangerousLoggingEnabled = false } = options; + const { authServerUrl, mcpServerUrl, demoMode, dangerousLoggingEnabled = false, autoConsent = false } = options; // Create better-auth instance with MCP plugin const auth = createDemoAuth({ @@ -116,6 +132,58 @@ export function setupAuthServer(options: SetupAuthServerOptions): void { // toNodeHandler bypasses Express methods const betterAuthHandler = toNodeHandler(auth); + // The issuer identifier this AS publishes in its metadata; must exactly match the + // `issuer` value better-auth emits at /.well-known/oauth-authorization-server. + const issuer = authServerUrl.toString().replace(/\/$/, ''); + const issuerOrigin = new URL(issuer).origin; + + // RFC 9207 (SEP-2468): append `iss` to every authorization-response redirect (success + // and error) that targets the client's redirect_uri. better-auth does not emit `iss` + // itself yet, so intercept the 302 Location header. Internal hops (to /sign-in or back + // to /api/auth/mcp/authorize) are left untouched. + authApp.use('/api/auth/mcp/authorize', (_req: Request, res: ExpressResponse, next: NextFunction) => { + const originalWriteHead = res.writeHead.bind(res); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + res.writeHead = function (statusCode: number, ...args: any[]) { + const headers = args.find(a => typeof a === 'object' && a !== null) as Record | undefined; + const loc = headers?.location ?? headers?.Location ?? (res.getHeader('Location') as string | undefined); + if (statusCode >= 300 && statusCode < 400 && loc && !loc.startsWith('/') && new URL(loc).origin !== issuerOrigin) { + const u = new URL(loc); + u.searchParams.set('iss', issuer); + if (headers && 'location' in headers) headers.location = u.href; + else if (headers && 'Location' in headers) headers.Location = u.href; + else res.setHeader('Location', u.href); + } + return originalWriteHead(statusCode, ...args); + } as typeof res.writeHead; + next(); + }); + + // DEMO ONLY: simulate the user clicking "Approve" on the consent screen. + // The SDK auth driver appends `prompt=consent` whenever it requests the + // `offline_access` scope (per OIDC §11). With a real user, better-auth + // would render a consent UI and wait for an explicit Approve; here we drop + // `prompt` from the query before it reaches better-auth so its authorize + // handler takes the no-consent fast path and 302s straight back to + // `redirect_uri?code=...`. See {@link SetupAuthServerOptions.autoConsent}. + if (autoConsent) { + authApp.use((req: Request, _res: ExpressResponse, next: NextFunction) => { + const qmark = req.url.indexOf('?'); + if (req.path === '/api/auth/mcp/authorize' && qmark !== -1) { + const search = new URLSearchParams(req.url.slice(qmark + 1)); + if (search.has('prompt')) { + search.delete('prompt'); + const qs = search.toString(); + // toNodeHandler reconstructs the Fetch Request from req.url + // (req.baseUrl is empty at the app level), so rewriting it + // here is what better-auth's handler will see. + req.url = `/api/auth/mcp/authorize${qs ? `?${qs}` : ''}`; + } + } + next(); + }); + } + // Mount better-auth handler BEFORE body parsers // toNodeHandler reads the raw request body, so Express must not consume it first if (dangerousLoggingEnabled) { @@ -166,7 +234,15 @@ export function setupAuthServer(options: SetupAuthServerOptions): void { // OAuth metadata endpoints using better-auth's built-in handlers // Add explicit OPTIONS handler for CORS preflight authApp.options('/.well-known/oauth-authorization-server', cors()); - authApp.get('/.well-known/oauth-authorization-server', cors(), toNodeHandler(oAuthDiscoveryMetadata(auth))); + // Wrap better-auth's metadata to advertise RFC 9207 support (the `iss` middleware + // above makes that claim true). + const discoveryHandler = oAuthDiscoveryMetadata(auth); + authApp.get('/.well-known/oauth-authorization-server', cors(), async (req: Request, res: ExpressResponse) => { + const upstream = await discoveryHandler(new Request(new URL(req.originalUrl, issuer))); + const body = (await upstream.json()) as Record; + body.authorization_response_iss_parameter_supported = true; + res.status(upstream.status).json(body); + }); // Body parsers for non-better-auth routes (like /sign-in) authApp.use(express.json()); diff --git a/examples/shared/src/clientCredentialsAuthServer.ts b/examples/shared/src/clientCredentialsAuthServer.ts new file mode 100644 index 0000000000..b4d87b0462 --- /dev/null +++ b/examples/shared/src/clientCredentialsAuthServer.ts @@ -0,0 +1,135 @@ +/** + * Minimal OAuth 2.0 Authorization Server supporting the **`client_credentials`** + * grant only — for the machine-to-machine MCP example. + * + * DEMO ONLY — NOT FOR PRODUCTION + * + * The full {@link setupAuthServer} (better-auth/OIDC) only supports the + * `authorization_code` grant; this is the headless counterpart so the + * `oauth-client-credentials/` example can be fully self-verifying without a + * browser. + * + * Exposes RFC 8414 metadata at `/.well-known/oauth-authorization-server` and a + * `/token` endpoint that accepts `client_secret_basic` or `client_secret_post` + * authentication. Issued access tokens are random opaque strings tracked in an + * in-memory map and validated by {@link clientCredentialsTokenVerifier}. + */ + +import { randomBytes } from 'node:crypto'; + +import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; +import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server'; +import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +import cors from 'cors'; +import express from 'express'; + +export interface RegisteredClient { + clientId: string; + clientSecret: string; + /** Scopes the AS is willing to grant this client (defaults to whatever it asks for). */ + allowedScopes?: string[]; +} + +export interface ClientCredentialsAuthServerOptions { + /** Public base URL of this AS (issuer). */ + authServerUrl: URL; + /** Pre-registered confidential clients. */ + clients: RegisteredClient[]; +} + +export interface ClientCredentialsAuthServer { + app: express.Application; + metadata: OAuthMetadata; + /** Pass to `requireBearerAuth({ verifier })` on the Resource Server. */ + verifier: OAuthTokenVerifier; +} + +/** Tokens issued by the most-recently-created `client_credentials` AS. */ +const issuedTokens = new Map(); + +/** + * Builds (but does not `listen()`) a minimal `client_credentials`-only + * Authorization Server. The caller mounts `app` on the port matching + * `authServerUrl`. + */ +export function createClientCredentialsAuthServer(options: ClientCredentialsAuthServerOptions): ClientCredentialsAuthServer { + const { authServerUrl, clients } = options; + const issuer = authServerUrl.href.replace(/\/$/, ''); + const clientById = new Map(clients.map(c => [c.clientId, c])); + + const metadata: OAuthMetadata = { + issuer, + token_endpoint: `${issuer}/token`, + // Required by the RFC 8414 schema even though this AS doesn't implement the endpoint. + authorization_endpoint: `${issuer}/authorize`, + response_types_supported: [], + grant_types_supported: ['client_credentials'], + token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'], + scopes_supported: ['mcp:tools', 'mcp:read'] + }; + + const app = express(); + app.use(cors()); + app.use(express.urlencoded({ extended: false })); + + app.get('/.well-known/oauth-authorization-server', (_req, res) => { + res.json(metadata); + }); + + app.post('/token', (req, res) => { + const body = req.body as Record; + if (body.grant_type !== 'client_credentials') { + res.status(400).json({ error: 'unsupported_grant_type' }); + return; + } + // RFC 6749 §2.3.1 — try Basic, then body. + let id: string | undefined; + let secret: string | undefined; + const authz = req.header('authorization'); + if (authz?.startsWith('Basic ')) { + const decoded = Buffer.from(authz.slice(6), 'base64').toString('utf8'); + const sep = decoded.indexOf(':'); + id = decodeURIComponent(decoded.slice(0, sep)); + secret = decodeURIComponent(decoded.slice(sep + 1)); + } else { + id = body.client_id; + secret = body.client_secret; + } + const client = id ? clientById.get(id) : undefined; + if (!client || client.clientSecret !== secret) { + res.status(401).set('WWW-Authenticate', 'Basic realm="oauth"').json({ error: 'invalid_client' }); + return; + } + const requested = (body.scope ?? '').split(' ').filter(Boolean); + const granted = client.allowedScopes ? requested.filter(s => client.allowedScopes!.includes(s)) : requested; + const accessToken = randomBytes(24).toString('base64url'); + const expiresIn = 3600; + issuedTokens.set(accessToken, { + token: accessToken, + clientId: client.clientId, + scopes: granted, + expiresAt: Math.floor(Date.now() / 1000) + expiresIn + }); + res.json({ access_token: accessToken, token_type: 'Bearer', expires_in: expiresIn, scope: granted.join(' ') }); + }); + + return { app, metadata, verifier: clientCredentialsTokenVerifier }; +} + +/** + * `OAuthTokenVerifier` that validates Bearer tokens against the in-memory + * issued-tokens map of {@link createClientCredentialsAuthServer}. + */ +export const clientCredentialsTokenVerifier: OAuthTokenVerifier = { + async verifyAccessToken(token): Promise { + const info = issuedTokens.get(token); + if (!info) throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token'); + // Model expiry explicitly even in the demo so copy-paste users don't ship a fail-open verifier. + // `requireBearerAuth` also independently rejects when `AuthInfo.expiresAt` is in the past. + if (info.expiresAt !== undefined && Math.floor(Date.now() / 1000) >= info.expiresAt) { + issuedTokens.delete(token); + throw new OAuthError(OAuthErrorCode.InvalidToken, 'token expired'); + } + return info; + } +}; diff --git a/examples/server/src/inMemoryEventStore.ts b/examples/shared/src/inMemoryEventStore.ts similarity index 100% rename from examples/server/src/inMemoryEventStore.ts rename to examples/shared/src/inMemoryEventStore.ts diff --git a/examples/shared/src/index.ts b/examples/shared/src/index.ts index 47c4d67109..3753ae4e4f 100644 --- a/examples/shared/src/index.ts +++ b/examples/shared/src/index.ts @@ -1,7 +1,7 @@ -// Auth configuration -export type { CreateDemoAuthOptions, DemoAuth } from './auth.js'; -export { createDemoAuth } from './auth.js'; - -// Auth server setup + demo token verifier (pass to `requireBearerAuth` from @modelcontextprotocol/express) -export type { SetupAuthServerOptions } from './authServer.js'; -export { createProtectedResourceMetadataRouter, demoTokenVerifier, getAuth, setupAuthServer } from './authServer.js'; +// Argv-parse + assert scaffold (NO SDK calls — those go inline in each story). +// This barrel is intentionally args-only so that the ~25 non-auth stories do +// not eagerly evaluate better-auth/express/cors/better-sqlite3 just by +// importing `parseExampleArgs`. The OAuth scaffolding lives at the `./auth` +// subpath — see `./indexAuth.ts`. +export type { ExampleArgs, ExampleEra, ExampleTransport } from './args.js'; +export { check, parseExampleArgs, siblingPath } from './args.js'; diff --git a/examples/shared/src/indexAuth.ts b/examples/shared/src/indexAuth.ts new file mode 100644 index 0000000000..0e9ec62c51 --- /dev/null +++ b/examples/shared/src/indexAuth.ts @@ -0,0 +1,19 @@ +// Auth + resumability scaffolding for the handful of stories that need it +// (`oauth`, `oauth-client-credentials`, `sse-polling`, `repl`). Kept off the +// root barrel so the other ~25 stories do not eagerly evaluate +// better-auth/express/cors/better-sqlite3 via `parseExampleArgs`. + +// Auth configuration +export type { CreateDemoAuthOptions, DemoAuth } from './auth.js'; +export { createDemoAuth } from './auth.js'; + +// Auth server setup + demo token verifier (pass to `requireBearerAuth` from @modelcontextprotocol/express) +export type { SetupAuthServerOptions } from './authServer.js'; +export { createProtectedResourceMetadataRouter, demoTokenVerifier, getAuth, setupAuthServer } from './authServer.js'; + +// In-memory EventStore for resumability examples (sse-polling, repl) +export { InMemoryEventStore } from './inMemoryEventStore.js'; + +// Minimal client_credentials-only AS (machine-to-machine; no browser) +export type { ClientCredentialsAuthServer, ClientCredentialsAuthServerOptions, RegisteredClient } from './clientCredentialsAuthServer.js'; +export { clientCredentialsTokenVerifier, createClientCredentialsAuthServer } from './clientCredentialsAuthServer.js'; diff --git a/examples/sse-polling/README.md b/examples/sse-polling/README.md new file mode 100644 index 0000000000..fef5261ba8 --- /dev/null +++ b/examples/sse-polling/README.md @@ -0,0 +1,12 @@ +# sse-polling + +SEP-1699 server-initiated SSE disconnection + client reconnection with `Last-Event-ID` replay. **Sessionful 2025** by definition (the feature lives on `NodeStreamableHTTPServerTransport` + an `EventStore`). `eventStore` resumability is a 2025-session concern with no 2026-07-28 +per-request equivalent. + +The `long-operation` tool emits two log notifications, calls `ctx.http?.closeSSE()` mid-stream, emits two more while the client is disconnected, then returns. The client transport reconnects after `retryInterval` (300 ms) with `Last-Event-ID`; the event store replays the buffered +events. The client asserts the result arrived AND the post-disconnect log was delivered. + +```bash +pnpm --filter @mcp-examples/sse-polling server -- --http --port 3001 # term 1 +pnpm --filter @mcp-examples/sse-polling client -- --http http://127.0.0.1:3001/mcp # term 2 +``` diff --git a/examples/sse-polling/client.ts b/examples/sse-polling/client.ts new file mode 100644 index 0000000000..7de08b147d --- /dev/null +++ b/examples/sse-polling/client.ts @@ -0,0 +1,48 @@ +/** + * SSE Polling Example Client (SEP-1699) + * + * Connects (2025-era), calls `long-operation`, and asserts the result arrives + * AFTER the server's mid-stream `closeSSE()` — i.e. the client transport + * automatically reconnects with `Last-Event-ID` and replays the events the + * `eventStore` buffered while disconnected. Also asserts every progress log + * (including the one emitted while disconnected) was delivered. + */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const { url } = parseExampleArgs(); + +// `closeSSE`/`eventStore` live on the sessionful-2025 transport, so this +// story is legacy-only by design — it was previously reaching 2025 by +// negotiation fallback; pin it. +const client = new Client({ name: 'sse-polling-client', version: '1.0.0' }, { versionNegotiation: { mode: 'legacy' } }); +const logs: string[] = []; +client.setNotificationHandler('notifications/message', n => { + logs.push(String(n.params.data)); +}); + +const transport = new StreamableHTTPClientTransport(new URL(url)); +// The mid-stream disconnect surfaces as a transport error before the +// automatic reconnect; that is the EXPECTED flow, not a failure. +transport.onerror = () => {}; +await client.connect(transport); + +let lastEventId: string | undefined; +const result = await client.request( + { method: 'tools/call', params: { name: 'long-operation', arguments: {} } }, + { onresumptiontoken: token => (lastEventId = token) } +); + +const text = (result as { content?: Array<{ type: string; text?: string }> }).content?.[0]?.text ?? ''; +check.match(text, /completed successfully/); +check.ok(lastEventId, 'resumption tokens should have been observed'); +// The 75% line is emitted WHILE the client is disconnected; receiving it +// proves the event store replayed it on reconnect. (Replay ordering relative +// to the terminal result is not asserted — the result resolving is the +// signal the disconnect was survived.) +check.ok( + logs.some(l => l.includes('75%')), + `events emitted while disconnected should be replayed (got: ${logs.join(' | ')})` +); + +await client.close(); diff --git a/examples/sse-polling/package.json b/examples/sse-polling/package.json new file mode 100644 index 0000000000..b66b6cad33 --- /dev/null +++ b/examples/sse-polling/package.json @@ -0,0 +1,30 @@ +{ + "name": "@mcp-examples/sse-polling", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "cors": "catalog:runtimeServerOnly", + "express": "catalog:runtimeServerOnly" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "legacy", + "path": "/mcp", + "timeoutMs": 20000, + "//": "SEP-1699 closeSSE/eventStore/retryInterval live on the sessionful-2025 transport; the client is era-blind so dual would duplicate." + } +} diff --git a/examples/server/src/ssePollingExample.ts b/examples/sse-polling/server.ts similarity index 55% rename from examples/server/src/ssePollingExample.ts rename to examples/sse-polling/server.ts index 2675a038ed..dbc011a84b 100644 --- a/examples/server/src/ssePollingExample.ts +++ b/examples/sse-polling/server.ts @@ -9,22 +9,22 @@ * - Uses `eventStore` to persist events for replay after reconnection * - Uses `ctx.http?.closeSSE()` callback to gracefully disconnect clients mid-operation * - * Run with: pnpm tsx src/ssePollingExample.ts - * Test with: curl or the MCP Inspector + * HTTP-only, sessionful 2025 by definition — `closeSSE`/`eventStore`/`retryInterval` + * live on `NodeStreamableHTTPServerTransport`, so this story wires that transport + * directly instead of the canonical `createMcpHandler` entry. */ import { randomUUID } from 'node:crypto'; +import { parseExampleArgs } from '@mcp-examples/shared'; +import { InMemoryEventStore } from '@mcp-examples/shared/auth'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { CallToolResult } from '@modelcontextprotocol/server'; -import { McpServer } from '@modelcontextprotocol/server'; +import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; import cors from 'cors'; import type { Request, Response } from 'express'; -import { InMemoryEventStore } from './inMemoryEventStore.js'; - -// Create a fresh MCP server per client connection to avoid shared state between clients -const getServer = () => { +function buildServer(): McpServer { const server = new McpServer( { name: 'sse-polling-example', @@ -44,33 +44,33 @@ const getServer = () => { async (ctx): Promise => { const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - console.log(`[${ctx.sessionId}] Starting long-operation...`); + console.error(`[${ctx.sessionId}] Starting long-operation...`); // Send first progress notification await ctx.mcpReq.log('info', 'Progress: 25% - Starting work...'); - await sleep(1000); + await sleep(200); // Send second progress notification await ctx.mcpReq.log('info', 'Progress: 50% - Halfway there...'); - await sleep(1000); + await sleep(200); // Server decides to disconnect the client to free resources // Client will reconnect via GET with Last-Event-ID after the transport's retryInterval // Use ctx.http?.closeSSE callback - available when eventStore is configured if (ctx.http?.closeSSE) { - console.log(`[${ctx.sessionId}] Closing SSE stream to trigger client polling...`); + console.error(`[${ctx.sessionId}] Closing SSE stream to trigger client polling...`); ctx.http?.closeSSE(); } // Continue processing while client is disconnected // Events are stored in eventStore and will be replayed on reconnect - await sleep(500); + await sleep(200); await ctx.mcpReq.log('info', 'Progress: 75% - Almost done (sent while client disconnected)...'); - await sleep(500); + await sleep(200); await ctx.mcpReq.log('info', 'Progress: 100% - Complete!'); - console.log(`[${ctx.sessionId}] Operation complete`); + console.error(`[${ctx.sessionId}] Operation complete`); return { content: [ @@ -84,7 +84,7 @@ const getServer = () => { ); return server; -}; +} // Set up Express app const app = createMcpExpressApp(); @@ -96,40 +96,38 @@ const eventStore = new InMemoryEventStore(); // Track transports by session ID for session reuse const transports = new Map(); -// Handle all MCP requests +// Handle all MCP requests (standard sessionful routing: known sid → reuse; +// no sid + initialize → new session; unknown sid → 404; otherwise → 400). app.all('/mcp', async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - - // Reuse existing transport or create new one - let transport = sessionId ? transports.get(sessionId) : undefined; - - if (!transport) { - transport = new NodeStreamableHTTPServerTransport({ + const sid = req.headers['mcp-session-id'] as string | undefined; + if (sid && transports.has(sid)) { + await transports.get(sid)!.handleRequest(req, res, req.body); + } else if (!sid && isInitializeRequest(req.body)) { + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore, - retryInterval: 2000, // Default retry interval for priming events + retryInterval: 300, // Default retry interval for priming events onsessioninitialized: id => { - console.log(`[${id}] Session initialized`); - transports.set(id, transport!); + console.error(`[${id}] Session initialized`); + transports.set(id, transport); } }); - - // Connect a fresh MCP server to the transport - const server = getServer(); - await server.connect(transport); + transport.onclose = () => transport.sessionId && transports.delete(transport.sessionId); + await buildServer().connect(transport); + await transport.handleRequest(req, res, req.body); + } else if (sid) { + // Unknown/expired session ID → 404 so the client knows to re-initialize. + res.status(404).json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }); + } else { + res.status(400).json({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: Session ID required' }, id: null }); } - - await transport.handleRequest(req, res, req.body); }); -// Start the server -const PORT = 3001; -app.listen(PORT, () => { - console.log(`SSE Polling Example Server running on http://localhost:${PORT}/mcp`); - console.log(''); - console.log('This server demonstrates SEP-1699 SSE polling:'); - console.log('- retryInterval: 2000ms (client waits 2s before reconnecting)'); - console.log('- eventStore: InMemoryEventStore (events are persisted for replay)'); - console.log(''); - console.log('Try calling the "long-operation" tool to see server-initiated disconnect in action.'); +const { port } = parseExampleArgs(); +app.listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + console.error('This server demonstrates SEP-1699 SSE polling:'); + console.error('- retryInterval: 300ms (client waits before reconnecting)'); + console.error('- eventStore: InMemoryEventStore (events are persisted for replay)'); + console.error('Try calling the "long-operation" tool to see server-initiated disconnect in action.'); }); diff --git a/examples/standalone-get/README.md b/examples/standalone-get/README.md new file mode 100644 index 0000000000..085ec69799 --- /dev/null +++ b/examples/standalone-get/README.md @@ -0,0 +1,8 @@ +# standalone-get + +Server-initiated `notifications/resources/list_changed` over the **standalone GET** SSE stream (sessionful 2025). The `add_resource` tool registers a new resource on the session's instance, which emits the notification over the GET stream the client opened via +`ClientOptions.listChanged`; the client calls the tool and asserts the notification arrived. + +The original timer-driven unsolicited push (server emits on its own schedule) was traded for this tool-triggered approach for CI determinism — the `list_changed`-over-standalone-GET behaviour is still demonstrated; "server pushes on its own schedule" is no longer shown. + +**HTTP-only**, sessionful 2025 by definition. diff --git a/examples/standalone-get/client.ts b/examples/standalone-get/client.ts new file mode 100644 index 0000000000..b62bf51d58 --- /dev/null +++ b/examples/standalone-get/client.ts @@ -0,0 +1,40 @@ +/** + * Connects (2025-era), opens the standalone GET stream by registering a + * `listChanged` handler, calls `add_resource` to trigger a + * `notifications/resources/list_changed` over that stream, and asserts it + * arrived. + */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const { url } = parseExampleArgs(); + +let received = 0; +const client = new Client( + { name: 'standalone-get-client', version: '1.0.0' }, + { + // Explicitly the 2025 `initialize` handshake — the standalone GET + // stream is a sessionful-2025 transport feature, so this story is + // legacy-only by design (was reaching 2025 by fallback; pin it). + versionNegotiation: { mode: 'legacy' }, + listChanged: { resources: { autoRefresh: false, debounceMs: 0, onChanged: () => void received++ } } + } +); +await client.connect(new StreamableHTTPClientTransport(new URL(url))); + +const before = await client.listResources(); +check.ok(before.resources.length > 0); + +// Mutate on demand → server emits list_changed over the standalone GET stream. +await client.callTool({ name: 'add_resource', arguments: { content: 'hello' } }); +const deadline = Date.now() + 5000; +while (received < 1) { + if (Date.now() > deadline) throw new Error('no listChanged within 5s'); + await new Promise(r => setTimeout(r, 25)); +} +check.ok(received >= 1); + +const after = await client.listResources(); +check.ok(after.resources.length > before.resources.length); + +await client.close(); diff --git a/examples/standalone-get/package.json b/examples/standalone-get/package.json new file mode 100644 index 0000000000..5a55605d77 --- /dev/null +++ b/examples/standalone-get/package.json @@ -0,0 +1,29 @@ +{ + "name": "@mcp-examples/standalone-get", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "express": "catalog:runtimeServerOnly", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "legacy", + "path": "/mcp", + "//": "The standalone GET stream is a sessionful-2025 transport feature; the client is era-blind so dual would duplicate." + } +} diff --git a/examples/standalone-get/server.ts b/examples/standalone-get/server.ts new file mode 100644 index 0000000000..5852cb40e8 --- /dev/null +++ b/examples/standalone-get/server.ts @@ -0,0 +1,99 @@ +/** + * Standalone GET stream + `notifications/resources/list_changed` (sessionful + * 2025). + * + * One `NodeStreamableHTTPServerTransport` + `McpServer` per session, the way + * you would deploy a sessionful 2025 server. The `add_resource` tool registers + * a new resource on the session's instance — `McpServer.registerResource` emits + * `notifications/resources/list_changed`, which on a sessionful transport + * travels over the **standalone GET** SSE stream the client opened. The client + * decides when to mutate (no timer race with the runner). + * + * **HTTP-only**, sessionful 2025 by definition — so the canonical + * `serveStdio` / `createMcpHandler` shape does not apply (per-request stateless + * has no GET stream). + */ +import { randomUUID } from 'node:crypto'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; +import type { Request, Response } from 'express'; +import * as z from 'zod/v4'; + +function buildServer(): McpServer { + const server = new McpServer( + { name: 'standalone-get-example', version: '1.0.0' }, + { capabilities: { resources: { listChanged: true } } } + ); + let nextId = 1; + const register = (name: string, content: string) => + server.registerResource( + name, + `https://mcp-example.com/dynamic/${encodeURIComponent(name)}`, + { mimeType: 'text/plain' }, + async uri => ({ + contents: [{ uri: uri.href, mimeType: 'text/plain', text: content }] + }) + ); + register('initial', 'Initial content'); + + server.registerTool( + 'add_resource', + { + description: + 'Register a new resource on this session — emits notifications/resources/list_changed over the standalone GET stream.', + inputSchema: z.object({ content: z.string() }) + }, + async ({ content }) => { + const name = `note-${nextId++}`; + register(name, content); + return { content: [{ type: 'text', text: `registered ${name}` }] }; + } + ); + return server; +} + +const sessions = new Map(); +const app = createMcpExpressApp(); + +app.post('/mcp', async (req: Request, res: Response) => { + const sid = req.headers['mcp-session-id'] as string | undefined; + if (sid && sessions.has(sid)) { + await sessions.get(sid)!.handleRequest(req, res, req.body); + } else if (!sid && isInitializeRequest(req.body)) { + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: id => { + sessions.set(id, transport); + } + }); + transport.onclose = () => transport.sessionId && sessions.delete(transport.sessionId); + await buildServer().connect(transport); + await transport.handleRequest(req, res, req.body); + } else if (sid) { + res.status(404).json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }); + } else { + res.status(400).json({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: Session ID required' }, id: null }); + } +}); + +// The standalone GET stream (the point of this story) and DELETE (explicit +// session termination per the MCP spec) route to the session's transport. +const sessionVerb = async (req: Request, res: Response) => { + const sid = req.headers['mcp-session-id'] as string | undefined; + const t = sid ? sessions.get(sid) : undefined; + if (!t) { + res.status(sid ? 404 : 400).send(sid ? 'Session not found' : 'Missing session ID'); + return; + } + await t.handleRequest(req, res); +}; +app.get('/mcp', sessionVerb); +app.delete('/mcp', sessionVerb); + +const { port } = parseExampleArgs(); +app.listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); +}); diff --git a/examples/stateless-legacy/README.md b/examples/stateless-legacy/README.md new file mode 100644 index 0000000000..d3b0250ec1 --- /dev/null +++ b/examples/stateless-legacy/README.md @@ -0,0 +1,5 @@ +# stateless-legacy + +The minimal `createMcpHandler` deployment, on its default posture: 2026-07-28 traffic served per request, 2025-era traffic served stateless from the same factory. This is the one-liner replacement for the 1.x "new transport + new server per POST" stateless idiom. + +**HTTP-only** by definition; see `dual-era/` for the stdio analogue. diff --git a/examples/stateless-legacy/client.ts b/examples/stateless-legacy/client.ts new file mode 100644 index 0000000000..2daca7df32 --- /dev/null +++ b/examples/stateless-legacy/client.ts @@ -0,0 +1,25 @@ +/** + * Connects to the minimal `createMcpHandler` deployment as both a plain 2025 + * client (`versionNegotiation: { mode: 'legacy' }` — the `initialize` + * handshake, served stateless from the factory) and a 2026-capable client + * (`versionNegotiation: { mode: 'auto' }`, served per request). Asserts the + * same `greet` tool answers identically either way. + * + * HTTP-only — `createMcpHandler`'s `legacy: 'stateless'` posture is an HTTP + * hosting concern; a stdio leg would bypass it. The story body drives BOTH + * eras itself, so only `url` is read from argv. + */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const { url } = parseExampleArgs(); + +for (const mode of ['legacy', 'auto'] as const) { + const client = new Client({ name: 'stateless-legacy-client', version: '1.0.0' }, { versionNegotiation: { mode } }); + await client.connect(new StreamableHTTPClientTransport(new URL(url))); + const tools = await client.listTools(); + check.ok(tools.tools.some(t => t.name === 'greet')); + const result = await client.callTool({ name: 'greet', arguments: { name: 'world' } }); + check.equal(result.content?.[0]?.type === 'text' ? result.content[0].text : '', 'Hello, world!'); + await client.close(); +} diff --git a/examples/stateless-legacy/package.json b/examples/stateless-legacy/package.json new file mode 100644 index 0000000000..8052f36367 --- /dev/null +++ b/examples/stateless-legacy/package.json @@ -0,0 +1,26 @@ +{ + "name": "@mcp-examples/stateless-legacy", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "modern", + "//": "The story body manages its own era internally; pinned so the runner runs it once per transport." + } +} diff --git a/examples/stateless-legacy/server.ts b/examples/stateless-legacy/server.ts new file mode 100644 index 0000000000..a51ee5ab48 --- /dev/null +++ b/examples/stateless-legacy/server.ts @@ -0,0 +1,36 @@ +/** + * The minimal `createMcpHandler` deployment, on its default posture. + * + * One factory, one endpoint: 2026-07-28 traffic is served per request, and + * 2025-era (non-envelope) traffic is served stateless from the same factory + * (`legacy: 'stateless'`, the default). This replaces the hand-wired + * "new transport + new server per POST" stateless idiom of the 1.x SDK with + * a one-liner. + * + * HTTP-only — `createMcpHandler`'s `legacy: 'stateless'` posture is an HTTP + * hosting concern; a stdio leg would bypass it. See `dual-era/` for the stdio + * analogue. + */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'stateless-legacy-example', version: '1.0.0' }, { capabilities: { logging: {} } }); + server.registerTool( + 'greet', + { description: 'A simple greeting tool', inputSchema: z.object({ name: z.string() }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}!` }] }) + ); + return server; +} + +const { port } = parseExampleArgs(); + +const handler = createMcpHandler(buildServer); +createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); +}); diff --git a/examples/stickynotes/README.md b/examples/stickynotes/README.md new file mode 100644 index 0000000000..13699fdd34 --- /dev/null +++ b/examples/stickynotes/README.md @@ -0,0 +1,8 @@ +# stickynotes + +The "real app" capstone: a sticky-notes board where tools mutate state, each note is a resource, the resource list changes on add/remove, and a destructive `remove_all` blocks on a form-mode elicitation. The client adds, lists, reads, removes, and proves `remove_all` only clears +the board on an explicit confirm. + +Runs all four transport/era legs. The `remove_all` confirmation is a push server→client elicitation (2025-era only — there is no server→client request channel on 2026-07-28; the equivalent is multi-round-trip `inputRequired`, see `../elicitation/`). The cancel / unchecked / +confirm flow is exercised on **stdio/legacy only** — `server.ts` hosts HTTP via a plain stateless `createMcpHandler`, whose per-request legacy fallback has no return path for the client's elicitation response — so the modern and http legs exercise add / list / read / remove and +skip `remove_all`. diff --git a/examples/stickynotes/client.ts b/examples/stickynotes/client.ts new file mode 100644 index 0000000000..e6a8fac9dc --- /dev/null +++ b/examples/stickynotes/client.ts @@ -0,0 +1,102 @@ +/** + * Drives the sticky-notes board end to end: add two notes, list/read their + * resources, remove one, then — on the 2025-era leg — attempt `remove_all` + * three ways (cancel, accept-unchecked, accept-confirmed) to prove the board is + * cleared only on an explicit confirmation. + */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +interface AddResult { + id: string; + uri: string; +} +interface RemoveAllResult { + status: string; + removed: number; +} + +const { transport, url, era } = parseExampleArgs(); + +const client = new Client( + { name: 'stickynotes-example-client', version: '1.0.0' }, + { + versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' }, + capabilities: { elicitation: { form: {} } } + } +); + +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + +let elicitAnswer: 'cancel' | 'unchecked' | 'confirm' = 'cancel'; +client.setRequestHandler('elicitation/create', async () => { + if (elicitAnswer === 'cancel') return { action: 'cancel' }; + return { action: 'accept', content: { confirm: elicitAnswer === 'confirm' } }; +}); + +// ADD two notes. +const first = await client.callTool({ name: 'add_note', arguments: { text: 'Buy milk' } }); +const firstNote = first.structuredContent as unknown as AddResult; +check.match(firstNote.uri, /^note:\/\/\//); +const second = await client.callTool({ name: 'add_note', arguments: { text: 'Walk the dog' } }); +const secondNote = second.structuredContent as unknown as AddResult; +check.notEqual(firstNote.id, secondNote.id); + +// LIST/READ — both notes should be listable resources. +const list = await client.listResources(); +const noteUris = new Set(list.resources.filter(r => r.uri.startsWith('note:///')).map(r => r.uri)); +check.ok(noteUris.has(firstNote.uri) && noteUris.has(secondNote.uri)); +const read = await client.readResource({ uri: firstNote.uri }); +const readContent = read.contents[0]; +check.equal(readContent && 'text' in readContent ? readContent.text : '', 'Buy milk'); + +// REMOVE ONE. +const removed = await client.callTool({ name: 'remove_note', arguments: { id: firstNote.id } }); +check.equal((removed.structuredContent as { removed?: boolean } | undefined)?.removed, true); +const after = await client.listResources(); +check.ok(!after.resources.some(r => r.uri === firstNote.uri)); + +// The elicitation-confirmed `remove_all` path is 2025-era stdio only: +// push-style server→client requests need a long-lived bidirectional +// connection that saw the `initialize` handshake (so the client's +// elicitation capability is advertised and the response can route back to +// the same server instance). On a 2026-07-28 connection there is no +// server→client request channel, and over `createMcpHandler`'s default +// stateless legacy fallback each HTTP request is a fresh per-request +// server — the equivalent is multi-round-trip `inputRequired` (see +// ../elicitation/). +if (era === 'modern' || transport === 'http') { + const removedSecond = await client.callTool({ name: 'remove_note', arguments: { id: secondNote.id } }); + check.equal((removedSecond.structuredContent as { removed?: boolean } | undefined)?.removed, true); + const afterClear = await client.listResources(); + check.equal(afterClear.resources.filter(r => r.uri.startsWith('note:///')).length, 0); +} else { + // CANCEL — board untouched. + elicitAnswer = 'cancel'; + const cancelled = await client.callTool({ name: 'remove_all' }); + check.equal((cancelled.structuredContent as unknown as RemoveAllResult).status, 'cancelled'); + const afterCancel = await client.listResources(); + check.ok(afterCancel.resources.some(r => r.uri === secondNote.uri)); + + // UNCHECKED — accept with confirm:false → declined, board untouched. + elicitAnswer = 'unchecked'; + const declined = await client.callTool({ name: 'remove_all' }); + check.equal((declined.structuredContent as unknown as RemoveAllResult).status, 'declined'); + + // CONFIRM — accept with confirm:true → cleared. + elicitAnswer = 'confirm'; + const cleared = await client.callTool({ name: 'remove_all' }); + check.equal((cleared.structuredContent as unknown as RemoveAllResult).status, 'cleared'); + check.equal((cleared.structuredContent as unknown as RemoveAllResult).removed, 1); + const afterClear = await client.listResources(); + check.equal(afterClear.resources.filter(r => r.uri.startsWith('note:///')).length, 0); + + // EMPTY — a follow-up remove_all reports 'empty' without eliciting. + const empty = await client.callTool({ name: 'remove_all' }); + check.equal((empty.structuredContent as unknown as RemoveAllResult).status, 'empty'); +} + +await client.close(); diff --git a/examples/stickynotes/package.json b/examples/stickynotes/package.json new file mode 100644 index 0000000000..2e5ce67e09 --- /dev/null +++ b/examples/stickynotes/package.json @@ -0,0 +1,23 @@ +{ + "name": "@mcp-examples/stickynotes", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "dual", + "//": "All four transport/era legs. The elicitation-confirmed remove_all path is exercised on stdio/legacy only (push-style server→client requests need a bidirectional connection that saw initialize; createMcpHandler's stateless legacy fallback has none); the modern and http legs exercise add/list/read/remove and skip remove_all." + } +} diff --git a/examples/stickynotes/server.ts b/examples/stickynotes/server.ts new file mode 100644 index 0000000000..c3baf45b48 --- /dev/null +++ b/examples/stickynotes/server.ts @@ -0,0 +1,127 @@ +/** + * "Real app" capstone — a small stateful sticky-notes board that ties + * together tools that mutate state, a resource per piece of state, listChanged + * on add/remove, and a server→client elicitation guarding a destructive action. + * + * The board is process-local (one map per server process). Over stdio one + * `McpServer` instance is pinned for the connection lifetime, so the tools + * register/unregister note resources at runtime; over the per-request HTTP + * path the factory registers a resource per live note on every request. + * + * Tools: + * - `add_note(text)` — store a note, register `note:///{id}`, returns + * `{id, uri}`. + * - `remove_note(id)` — delete one note + unregister its resource. + * - `remove_all()` — delete every note, but FIRST blocks on a form-mode + * elicitation; declining/cancelling/unchecked all leave the board. + * + * One binary, either transport. + */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import type { RegisteredResource } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +const notes = new Map(); +let nextId = 1; +const uriFor = (id: string) => `note:///${id}`; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'stickynotes-example', version: '1.0.0' }, { capabilities: { resources: { listChanged: true } } }); + // Registrations on THIS instance (so the stdio leg can unregister at runtime). + const registered = new Map(); + const registerNote = (id: string, text: string) => { + const r = server.registerResource( + `note-${id}`, + uriFor(id), + { mimeType: 'text/plain', description: `Sticky note #${id}` }, + async uri => ({ + contents: [{ uri: uri.href, mimeType: 'text/plain', text: notes.get(id) ?? text }] + }) + ); + registered.set(id, r); + }; + // Register a resource per live note (per-request HTTP path picks up the + // current board on every factory call; stdio re-uses one instance). + for (const [id, text] of notes) registerNote(id, text); + + server.registerTool( + 'add_note', + { + description: 'Add a sticky note; registers a note:///{id} resource for it.', + inputSchema: z.object({ text: z.string() }), + outputSchema: z.object({ id: z.string(), uri: z.string() }) + }, + async ({ text }) => { + const id = String(nextId++); + notes.set(id, text); + registerNote(id, text); + const structuredContent = { id, uri: uriFor(id) }; + return { content: [{ type: 'text', text: `added note #${id}` }], structuredContent }; + } + ); + + server.registerTool( + 'remove_note', + { + description: 'Remove one sticky note by id and unregister its resource.', + inputSchema: z.object({ id: z.string() }), + outputSchema: z.object({ removed: z.boolean(), id: z.string() }) + }, + async ({ id }) => { + const removed = notes.delete(id); + if (removed) registered.get(id)?.remove(); + return { content: [{ type: 'text', text: removed ? `removed #${id}` : 'not found' }], structuredContent: { removed, id } }; + } + ); + + server.registerTool( + 'remove_all', + { + description: 'Remove ALL sticky notes after confirming via a server→client elicitation.', + outputSchema: z.object({ status: z.string(), removed: z.number() }) + }, + async ctx => { + if (notes.size === 0) { + return { content: [{ type: 'text', text: 'nothing to clear' }], structuredContent: { status: 'empty', removed: 0 } }; + } + const count = notes.size; + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: `Remove all ${count} sticky note(s)? This cannot be undone.`, + requestedSchema: { + type: 'object', + properties: { confirm: { type: 'boolean', title: 'Yes, permanently delete every sticky note' } }, + required: ['confirm'] + } + }); + if (result.action === 'cancel') { + return { content: [{ type: 'text', text: 'cancelled' }], structuredContent: { status: 'cancelled', removed: 0 } }; + } + if (result.action !== 'accept' || !(result.content as { confirm?: boolean } | undefined)?.confirm) { + return { content: [{ type: 'text', text: 'declined' }], structuredContent: { status: 'declined', removed: 0 } }; + } + for (const id of notes.keys()) registered.get(id)?.remove(); + notes.clear(); + return { content: [{ type: 'text', text: `cleared ${count}` }], structuredContent: { status: 'cleared', removed: count } }; + } + ); + + return server; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/streaming/README.md b/examples/streaming/README.md new file mode 100644 index 0000000000..a62b1f1496 --- /dev/null +++ b/examples/streaming/README.md @@ -0,0 +1,8 @@ +# streaming + +The three in-flight channels: progress (via `_meta.progressToken` → `notifications/progress` → the client's `onprogress` callback), logging (`ctx.mcpReq.notify({ method: 'notifications/message', … })` — request-tied so it rides the same response stream as progress; the +connection-level `ctx.mcpReq.log` shorthand sends an unrelated notification a per-request HTTP entry cannot deliver mid-call), and cancellation (the client's `AbortSignal` → `ctx.mcpReq.signal.aborted` server-side). + +```bash +pnpm tsx examples/streaming/client.ts +``` diff --git a/examples/streaming/client.ts b/examples/streaming/client.ts new file mode 100644 index 0000000000..d1cc249aca --- /dev/null +++ b/examples/streaming/client.ts @@ -0,0 +1,54 @@ +/** + * Drives the streaming example: a `countdown` call with `onprogress` + * (asserts progress notifications arrived), a logging-notification handler + * (asserts log messages arrived), and a cancelled call (asserts the cancel + * propagated). + */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const { transport, url, era } = parseExampleArgs(); + +const client = new Client( + { name: 'streaming-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); + +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + +let logCount = 0; +client.setNotificationHandler('notifications/message', () => { + logCount++; +}); + +// --- progress + logging --- +let progressCount = 0; +const result = await client.callTool( + { name: 'countdown', arguments: { n: 5, delayMs: 20 } }, + { + onprogress: p => { + progressCount++; + check.equal(p.total, 5); + } + } +); +check.equal((result.structuredContent as { completed?: number } | undefined)?.completed, 5); +check.equal((result.structuredContent as { cancelled?: boolean } | undefined)?.cancelled, false); +check.ok(progressCount >= 4, `expected >=4 progress notifications, got ${progressCount}`); +check.ok(logCount >= 4, `expected >=4 log notifications, got ${logCount}`); + +// --- cancellation propagation --- +const ac = new AbortController(); +setTimeout(() => ac.abort(), 60); +let cancelled = false; +try { + await client.callTool({ name: 'countdown', arguments: { n: 50, delayMs: 50 } }, { signal: ac.signal }); +} catch { + cancelled = true; +} +check.ok(cancelled, 'a client-side abort should reject the in-flight callTool'); + +await client.close(); diff --git a/examples/streaming/package.json b/examples/streaming/package.json new file mode 100644 index 0000000000..381e19a7e6 --- /dev/null +++ b/examples/streaming/package.json @@ -0,0 +1,19 @@ +{ + "name": "@mcp-examples/streaming", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + } +} diff --git a/examples/streaming/server.ts b/examples/streaming/server.ts new file mode 100644 index 0000000000..0e45e320a7 --- /dev/null +++ b/examples/streaming/server.ts @@ -0,0 +1,71 @@ +/** + * In-flight channels: progress, logging, cancellation. + * + * The `countdown` tool emits a `notifications/progress` per step (when the + * call carried a `_meta.progressToken`), a logging notification per step + * (when the server has the `logging` capability), and stops promptly when the + * client cancels (`ctx.mcpReq.signal.aborted`). One binary, either transport. + */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'streaming-example', version: '1.0.0' }, { capabilities: { logging: {} } }); + + server.registerTool( + 'countdown', + { + description: 'Counts down from N, emitting progress + log per step; stops on cancellation', + inputSchema: z.object({ n: z.number().int().min(1).max(50), delayMs: z.number().int().min(0).default(50) }), + outputSchema: z.object({ completed: z.number(), total: z.number(), cancelled: z.boolean() }) + }, + async ({ n, delayMs }, ctx) => { + const progressToken = ctx.mcpReq._meta?.progressToken; + let completed = 0; + for (let i = 0; i < n; i++) { + if (ctx.mcpReq.signal.aborted) break; + await new Promise(r => setTimeout(r, delayMs)); + completed++; + if (progressToken !== undefined) { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken, progress: completed, total: n, message: `step ${completed}/${n}` } + }); + } + // Send the log message as a request-tied notification so it + // rides the same response stream as the progress notification + // (the connection-level `ctx.mcpReq.log` shorthand sends an + // unrelated notification, which a per-request HTTP entry + // cannot deliver mid-call). + await ctx.mcpReq.notify({ + method: 'notifications/message', + params: { level: 'info', logger: 'countdown', data: `countdown step ${completed}/${n}` } + }); + } + const structuredContent = { completed, total: n, cancelled: ctx.mcpReq.signal.aborted }; + return { + content: [{ type: 'text', text: `completed ${completed}/${n}${structuredContent.cancelled ? ' (cancelled)' : ''}` }], + structuredContent + }; + } + ); + + return server; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/subscriptions/README.md b/examples/subscriptions/README.md new file mode 100644 index 0000000000..c7e9d5ce3c --- /dev/null +++ b/examples/subscriptions/README.md @@ -0,0 +1,16 @@ +# subscriptions + +`subscriptions/listen` change-notification streams (protocol revision 2026-07-28). The server publishes `tools/list_changed`; the client receives it both via the auto-opened stream (`ClientOptions.listChanged`, the same option a 2025-era client sets) and a manual +`client.listen()` call. + +The publish surface differs by entry: over HTTP (`createMcpHandler`) the example calls `handler.notify.toolsChanged()` on the cross-request `ServerEventBus`; over stdio (`serveStdio`) it toggles a `RegisteredTool` on the pinned instance, whose `tools/list_changed` the entry's +listen router fans onto every open subscription. + +```bash +# stdio (the client spawns the server itself): +pnpm tsx examples/subscriptions/client.ts + +# Streamable HTTP (two terminals): +pnpm tsx examples/subscriptions/server.ts --http --port 3000 +pnpm tsx examples/subscriptions/client.ts --http http://127.0.0.1:3000/ +``` diff --git a/examples/subscriptions/client.ts b/examples/subscriptions/client.ts new file mode 100644 index 0000000000..9ac1a6fbbb --- /dev/null +++ b/examples/subscriptions/client.ts @@ -0,0 +1,90 @@ +/** + * Drives the `subscriptions/listen` server (`./server.ts`) two ways on a + * 2026-07-28 connection: + * + * 1. **auto-open via `ClientOptions.listChanged`** — the same option a + * 2025-era client sets; on a modern connection the SDK auto-opens a + * listen stream with the filter derived from which sub-options were set, + * so the configured `onChanged` handlers fire on every published change; + * 2. **manual `client.listen()`** — opens a stream explicitly, registers a + * `notifications/tools/list_changed` handler the stream feeds, and closes + * after a few notifications. + * + * The example calls `flip_tools` to mutate the server's tool set on demand + * (rather than a timer), then asserts the change notification arrived. + */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import type { ClientOptions, McpSubscription } from '@modelcontextprotocol/client'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +/** Wait until `pred()` is true or `timeoutMs` elapses. */ +async function until(pred: () => boolean, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (!pred()) { + if (Date.now() > deadline) throw new Error('timed out waiting for change notification'); + await new Promise(r => setTimeout(r, 25)); + } +} + +const { transport, url } = parseExampleArgs(); + +// Both legs connect identically and differ only in ClientOptions; the local +// helper keeps the SDK transport setup visible in THIS file (the canonical +// shape) while avoiding duplicating it for each leg. Modern-only — +// `subscriptions/listen` is a 2026-07-28 protocol feature. +const connect = async (options?: ClientOptions): Promise => { + const client = new Client( + { name: 'subscriptions-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' }, ...options } + ); + await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + return client; +}; + +// --- auto-open via ClientOptions.listChanged --- +{ + let count = 0; + const client = await connect({ + listChanged: { + tools: { + autoRefresh: false, + // The default debounce coalesces bursts; this example asserts + // raw delivery, so disable it. + debounceMs: 0, + onChanged: () => void count++ + } + } + }); + check.ok(client.autoOpenedSubscription, 'a listChanged option should auto-open a subscription on a modern connection'); + check.ok(client.autoOpenedSubscription?.honoredFilter.toolsListChanged, 'auto-opened filter should include toolsListChanged'); + + await client.callTool({ name: 'flip_tools' }); + await until(() => count >= 1); + await client.callTool({ name: 'flip_tools' }); + await until(() => count >= 2); + + await client.autoOpenedSubscription?.close(); + await client.close(); + check.ok(count >= 2, 'auto-open leg should receive at least two tools/list_changed'); +} + +// --- manual client.listen() --- +{ + const client = await connect(); + let count = 0; + client.setNotificationHandler('notifications/tools/list_changed', () => void count++); + const sub: McpSubscription = await client.listen({ toolsListChanged: true }); + check.ok(sub.honoredFilter.toolsListChanged, 'manual listen should honor toolsListChanged'); + + await client.callTool({ name: 'flip_tools' }); + await until(() => count >= 1); + await client.callTool({ name: 'flip_tools' }); + await until(() => count >= 2); + + await sub.close(); + await client.close(); + check.ok(count >= 2, 'manual leg should receive at least two tools/list_changed'); +} diff --git a/examples/subscriptions/package.json b/examples/subscriptions/package.json new file mode 100644 index 0000000000..880618ea28 --- /dev/null +++ b/examples/subscriptions/package.json @@ -0,0 +1,23 @@ +{ + "name": "@mcp-examples/subscriptions", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "era": "modern", + "//": "subscriptions/listen is a 2026-07-28 protocol feature." + } +} diff --git a/examples/subscriptions/server.ts b/examples/subscriptions/server.ts new file mode 100644 index 0000000000..4681de1693 --- /dev/null +++ b/examples/subscriptions/server.ts @@ -0,0 +1,89 @@ +/** + * `subscriptions/listen` change notifications (protocol revision 2026-07-28). + * + * One factory, either transport — but the publish surface differs by entry: + * + * - **HTTP** (`createMcpHandler`): the handler exposes `.notify` + * ({@link ServerNotifier}) over its cross-request {@link ServerEventBus}; + * `handler.notify.toolsChanged()` reaches every open `subscriptions/listen` + * stream that opted in to `toolsListChanged`. + * - **stdio** (`serveStdio`): one `McpServer` instance is pinned for the + * connection; toggling a `RegisteredTool` (`.enable()/.disable()`) emits the + * instance's own `notifications/tools/list_changed`, which the stdio entry's + * listen router fans onto every open subscription. + * + * The `flip_tools` tool toggles the `farewell` tool and publishes the change, + * so the client decides when to mutate (no timer race with the runner). The + * canonical-shape transport branch below assigns `publish` per entry. + */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import type { RegisteredTool, ServerEventBus, ServerNotifier } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +let extraToolEnabled = false; +/** + * Publishes `tools/list_changed` to every open subscription. Assigned by the + * transport branch below: `handler.notify.toolsChanged()` over HTTP; toggling + * the pinned instance's `RegisteredTool` over stdio. + */ +let publish: () => void = () => {}; + +function buildServer(): McpServer { + const server = new McpServer( + { name: 'subscriptions-listen-example', version: '1.0.0' }, + { capabilities: { tools: { listChanged: true } } } + ); + + server.registerTool('greet', { description: 'Returns a greeting', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => ({ + content: [{ type: 'text', text: `hello, ${name}` }] + })); + const farewell: RegisteredTool = server.registerTool( + 'farewell', + { description: 'Returns a farewell', inputSchema: z.object({ name: z.string() }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `goodbye, ${name}` }] }) + ); + if (!extraToolEnabled) farewell.disable(); + + server.registerTool( + 'flip_tools', + { description: 'Toggle the farewell tool and publish tools/list_changed to every open subscription' }, + async () => { + extraToolEnabled = !extraToolEnabled; + // Over stdio this `update` IS the publish (the entry's listen + // router fans the instance's outbound list_changed onto every open + // subscription); over HTTP it just keeps this per-request instance + // consistent and `publish()` reaches the cross-request bus. + farewell.update({ enabled: extraToolEnabled }); + publish(); + return { content: [{ type: 'text', text: `farewell ${extraToolEnabled ? 'enabled' : 'disabled'}` }] }; + } + ); + + return server; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + // Over stdio the per-instance `farewell.update` inside `flip_tools` IS the + // publish, so `publish` stays a no-op here. + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + // Host with the per-request HTTP entry on its default posture. The handler + // creates an in-process bus by default; supply your own `bus` for + // multi-process deployments. + const handler = createMcpHandler(buildServer); + const bus: ServerEventBus = handler.bus; + const notify: ServerNotifier = handler.notify; + void bus; // (the typed publish facade `notify` wraps `bus.publish`) + publish = () => notify.toolsChanged(); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/tools/README.md b/examples/tools/README.md new file mode 100644 index 0000000000..6d0bf11784 --- /dev/null +++ b/examples/tools/README.md @@ -0,0 +1,8 @@ +# tools + +**Start here.** Register tools with `McpServer.registerTool`; the SDK infers the JSON Schema from any Standard-Schema-compatible input (Zod here) and emits `structuredContent` matching `outputSchema`. The client lists tools, inspects schemas and `annotations`, calls them, and +asserts structured output. + +```bash +pnpm tsx examples/tools/client.ts +``` diff --git a/examples/tools/client.ts b/examples/tools/client.ts new file mode 100644 index 0000000000..f546d19e14 --- /dev/null +++ b/examples/tools/client.ts @@ -0,0 +1,49 @@ +/** + * Drives the tools example: list, inspect schemas + annotations, call, + * assert structured output, assert an unknown tool errors. + */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const { transport, url, era } = parseExampleArgs(); + +const client = new Client( + { name: 'tools-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); + +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + +const list = await client.listTools(); +const names = new Set(list.tools.map(t => t.name)); +check.ok(names.has('calc') && names.has('echo'), 'tools/list should contain calc and echo'); + +const calc = list.tools.find(t => t.name === 'calc')!; +check.equal(calc.annotations?.readOnlyHint, true); +const required = (calc.inputSchema as { required?: string[] }).required ?? []; +check.ok(required.includes('op') && required.includes('a') && required.includes('b')); +check.ok(calc.outputSchema, 'calc should publish an outputSchema'); +check.equal(calc.icons?.[0]?.src, 'https://example.test/calc.svg', 'calc should advertise its icons over the wire'); + +const result = await client.callTool({ name: 'calc', arguments: { op: 'add', a: 2, b: 3 } }); +check.equal((result.structuredContent as { result?: number } | undefined)?.result, 5); +check.equal((result.structuredContent as { op?: string } | undefined)?.op, 'add'); + +const echo = await client.callTool({ name: 'echo', arguments: { text: 'hi' } }); +check.equal(echo.content?.[0]?.type === 'text' ? echo.content[0].text : '', 'hi'); +check.equal(echo.structuredContent, undefined); + +// An unknown tool should be a tool error (isError) or a wire error — either is acceptable. +let unknownFailed = false; +try { + const r = await client.callTool({ name: 'nope', arguments: {} }); + unknownFailed = !!r.isError; +} catch { + unknownFailed = true; +} +check.ok(unknownFailed, 'calling an unknown tool should fail'); + +await client.close(); diff --git a/examples/tools/package.json b/examples/tools/package.json new file mode 100644 index 0000000000..d54e05fc22 --- /dev/null +++ b/examples/tools/package.json @@ -0,0 +1,19 @@ +{ + "name": "@mcp-examples/tools", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + } +} diff --git a/examples/tools/server.ts b/examples/tools/server.ts new file mode 100644 index 0000000000..3456aa1027 --- /dev/null +++ b/examples/tools/server.ts @@ -0,0 +1,65 @@ +/** + * Tools primitive — start here. + * + * Register tools with `McpServer.registerTool`: typed input via any + * Standard-Schema-with-JSON library (Zod here), inferred output schema + + * `structuredContent` from `outputSchema`, `annotations` for behavioral hints + * (`readOnlyHint`, `destructiveHint`). One binary, either transport. + */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'tools-example', version: '1.0.0' }); + + // A read-only tool with typed input and inferred structured output. + server.registerTool( + 'calc', + { + title: 'Calculator', + description: 'Apply an arithmetic operation to two numbers', + inputSchema: z.object({ + op: z.enum(['add', 'sub', 'mul']).describe('the operation to apply'), + a: z.number().describe('left operand'), + b: z.number().describe('right operand') + }), + outputSchema: z.object({ op: z.string(), result: z.number() }), + annotations: { readOnlyHint: true, idempotentHint: true }, + // Icons a client may render in its UI. `src` is required; + // `mimeType`, `sizes`, and `theme` are optional hints. + icons: [{ src: 'https://example.test/calc.svg', mimeType: 'image/svg+xml', sizes: ['any'] }] + }, + async ({ op, a, b }) => { + const result = op === 'add' ? a + b : op === 'sub' ? a - b : a * b; + const structuredContent = { op, result }; + return { content: [{ type: 'text', text: `${a} ${op} ${b} = ${result}` }], structuredContent }; + } + ); + + // A plain string-returning tool (no structuredContent). + server.registerTool( + 'echo', + { description: 'Echoes the input', inputSchema: z.object({ text: z.string() }) }, + async ({ text }): Promise => ({ content: [{ type: 'text', text }] }) + ); + + return server; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + createServer(toNodeHandler(handler)).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/server/tsconfig.json b/examples/tsconfig.json similarity index 68% rename from examples/server/tsconfig.json rename to examples/tsconfig.json index 37a3e874f7..f8f4ab184e 100644 --- a/examples/server/tsconfig.json +++ b/examples/tsconfig.json @@ -1,13 +1,17 @@ { "extends": "@modelcontextprotocol/tsconfig", "include": ["./"], - "exclude": ["node_modules", "dist"], + "exclude": ["node_modules", "dist", "shared", "server-quickstart", "client-quickstart"], "compilerOptions": { + "noEmit": true, "paths": { "*": ["./*"], "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], "@modelcontextprotocol/server/stdio": ["./node_modules/@modelcontextprotocol/server/src/stdio.ts"], "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], + "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], + "@modelcontextprotocol/client/stdio": ["./node_modules/@modelcontextprotocol/client/src/stdio.ts"], + "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], "@modelcontextprotocol/hono": ["./node_modules/@modelcontextprotocol/hono/src/index.ts"], @@ -17,9 +21,7 @@ "@modelcontextprotocol/core/public": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/exports/public/index.ts" ], - "@modelcontextprotocol/examples-shared": ["./node_modules/@modelcontextprotocol/examples-shared/src/index.ts"], - "@modelcontextprotocol/eslint-config": ["./node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"], - "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"] + "@mcp-examples/shared": ["./node_modules/@mcp-examples/shared/src/index.ts"] } } } diff --git a/package.json b/package.json index d1ecc0c627..261ad9b92d 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,12 @@ "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", + "examples:oauth-server:w": "pnpm --filter @mcp-examples/oauth exec tsx --watch server.ts", + "run:examples": "tsx scripts/examples/run-examples.ts", "docs": "typedoc", "docs:multi": "bash scripts/generate-multidoc.sh", "docs:check": "typedoc", @@ -41,19 +44,19 @@ "test:conformance:client:run": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client:run", "test:conformance:server": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server", "test:conformance:server:draft": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:draft", + "test:conformance:server:extensions": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:extensions", "test:conformance:server:all": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:all", "test:conformance:server:run": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:run", "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 +70,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/README.md b/packages/client/README.md index 589f566350..e9ce66f511 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -8,7 +8,7 @@ The MCP (Model Context Protocol) TypeScript client SDK. Build MCP clients that c > [!NOTE] -> This is **v2** of the MCP TypeScript SDK. It replaces the monolithic `@modelcontextprotocol/sdk` package from v1. See the **[migration guide](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration.md)** if you're coming from v1. +> This is **v2** of the MCP TypeScript SDK. It replaces the monolithic `@modelcontextprotocol/sdk` package from v1. See the **[migration guide](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration/upgrade-to-v2.md)** if you're coming from v1. ## Install diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 5f55fb7a08..68ae5fe8f5 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -8,7 +8,9 @@ import type { OAuthClientMetadata, OAuthMetadata, OAuthProtectedResourceMetadata, - OAuthTokens + OAuthTokens, + StoredOAuthClientInformation, + StoredOAuthTokens } from '@modelcontextprotocol/core'; import { checkResourceAllowed, @@ -25,6 +27,21 @@ import { } from '@modelcontextprotocol/core'; import pkceChallenge from 'pkce-challenge'; +import { + AuthorizationServerMismatchError, + InsecureTokenEndpointError, + IssuerMismatchError, + RegistrationRejectedError +} from './authErrors.js'; + +// Re-exported for back-compat — the canonical home is ./authErrors.js. +export { + AuthorizationServerMismatchError, + InsecureTokenEndpointError, + IssuerMismatchError, + RegistrationRejectedError +} from './authErrors.js'; + /** * Function type for adding client authentication to token requests. */ @@ -82,6 +99,71 @@ export interface AuthProvider { onUnauthorized?(ctx: UnauthorizedContext): Promise; } +/** + * Context passed to the credential-persistence methods on + * {@linkcode OAuthClientProvider} — `clientInformation` / `saveClientInformation` + * and `tokens` / `saveTokens`. Carries the resolved authorization-server `issuer` + * so provider implementations can key persisted credentials per authorization + * server (RFC 6749 §2.2 — client identifiers are unique to the AS that issued + * them). Providers that store a single credential set may ignore it. + */ +export interface OAuthClientInformationContext { + /** + * The authorization server's `issuer` identifier from its validated metadata + * document, used as the binding key for persisted credentials. + */ + issuer: string; +} + +/** + * SEP-2352 stamp check: returns `stored` only when its `issuer` stamp matches the + * resolved authorization server. A stamp that names a *different* issuer reads back + * as `undefined`, so a credential issued by one authorization server is never reused + * at another — the flow falls through to re-registration / re-authorization exactly + * as if nothing were stored. An unstamped value (legacy provider or pre-SEP-2352 + * storage) is returned as-is with a `console.warn`; {@linkcode auth} writes the + * stamp back on first use so the window closes after one call. + * + * {@linkcode auth} stamps every value it writes via `saveTokens` / `saveClientInformation`, + * so a provider that round-trips the stored object verbatim is protected with no extra + * code. Providers that hold credentials for multiple authorization servers key their + * storage on `ctx.issuer` instead. + * + * @param opts.canPersistStamp - When `false`, suppresses the unstamped-credential + * warning: the caller cannot back-stamp (no `saveClientInformation`), so the + * "binding on first use" claim would be false and would fire on every call. + */ +export function discardIfIssuerMismatch( + stored: T | undefined, + issuer: string, + opts?: { canPersistStamp?: boolean } +): T | undefined { + if (stored === undefined) return undefined; + if (stored.issuer === undefined) { + if (opts?.canPersistStamp !== false) { + console.warn( + `[mcp-sdk] SEP-2352: stored OAuth credential has no 'issuer' stamp (pre-upgrade storage or ` + + `provider not round-tripping the value). SEP-2352 isolation is inactive for this read; ` + + `ensure your provider round-trips the issuer field.` + ); + } + return stored; + } + return issuersMatch(stored.issuer, issuer) ? stored : undefined; +} + +/** + * SEP-2352 issuer-identity comparison. Tolerates a single trailing `/` difference, + * mirroring the RFC 8414 §3.3 "one narrow tolerance" applied at metadata-echo + * validation in {@linkcode discoverAuthorizationServerMetadata}: when the SDK + * derives an issuer from `String(new URL(...))` (always slash-suffixed) and the AS + * publishes a slash-free `metadata.issuer`, the two name the same authorization + * server. + */ +function issuersMatch(a: string, b: string): boolean { + return a === b || (a.endsWith('/') && a.slice(0, -1) === b) || (b.endsWith('/') && b.slice(0, -1) === a); +} + /** * Type guard distinguishing `OAuthClientProvider` from a minimal `AuthProvider`. * Transports use this at construction time to classify the `authProvider` option. @@ -100,13 +182,18 @@ export function isOAuthClientProvider(provider: AuthProvider | OAuthClientProvid * `WWW-Authenticate` parameters from the 401 response and runs {@linkcode auth}. * Used by {@linkcode adaptOAuthProvider} to bridge `OAuthClientProvider` to `AuthProvider`. */ -export async function handleOAuthUnauthorized(provider: OAuthClientProvider, ctx: UnauthorizedContext): Promise { +export async function handleOAuthUnauthorized( + provider: OAuthClientProvider, + ctx: UnauthorizedContext, + extraAuthOptions?: Pick +): Promise { const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(ctx.response); const result = await auth(provider, { serverUrl: ctx.serverUrl, resourceMetadataUrl, scope, - fetchFn: ctx.fetchFn + fetchFn: ctx.fetchFn, + ...extraAuthOptions }); if (result !== 'AUTHORIZED') { throw new UnauthorizedError(); @@ -117,15 +204,27 @@ export async function handleOAuthUnauthorized(provider: OAuthClientProvider, ctx * Adapts an `OAuthClientProvider` to the minimal `AuthProvider` interface that * transports consume. Called once at transport construction — the transport stores * the adapted provider for `_commonHeaders()` and 401 handling, while keeping the - * original `OAuthClientProvider` for OAuth-specific paths (`finishAuth()`, 403 upscoping). + * original `OAuthClientProvider` for OAuth-specific paths (`finishAuth()`, 403 `insufficient_scope` step-up). + * + * SEP-2352 note: `token()` here is the per-request `Authorization: Bearer …` read for + * the *resource server* (the MCP transport URL), not an authorization server. No OAuth + * discovery has run at this layer, so there is no `issuer` to pass as `ctx` and no + * {@linkcode discardIfIssuerMismatch} check to apply — the access token is sent only to + * the resource server, never to an AS, so the SEP-2352 cross-AS isolation invariant is + * not in scope. Providers that key storage on `ctx.issuer` MUST treat `ctx === undefined` + * as "return the most-recently-saved token set" (the only consumer is the resource server + * the token was minted for); providers that round-trip a single blob need no change. */ -export function adaptOAuthProvider(provider: OAuthClientProvider): AuthProvider { +export function adaptOAuthProvider( + provider: OAuthClientProvider, + extraAuthOptions?: Pick +): AuthProvider { return { token: async () => { const tokens = await provider.tokens(); return tokens?.access_token; }, - onUnauthorized: async ctx => handleOAuthUnauthorized(provider, ctx) + onUnauthorized: async ctx => handleOAuthUnauthorized(provider, ctx, extraAuthOptions) }; } @@ -167,8 +266,14 @@ export interface OAuthClientProvider { * Loads information about this OAuth client, as registered already with the * server, or returns `undefined` if the client is not registered with the * server. + * + * @param ctx - Carries the resolved authorization-server `issuer`. Providers + * that persist credentials per authorization server should return the entry + * keyed by `ctx.issuer`. Providers with a single credential set may ignore it. */ - clientInformation(): OAuthClientInformationMixed | undefined | Promise; + clientInformation( + ctx?: OAuthClientInformationContext + ): StoredOAuthClientInformation | undefined | Promise; /** * If implemented, this permits the OAuth client to dynamically register with @@ -177,20 +282,35 @@ export interface OAuthClientProvider { * * This method is not required to be implemented if client information is * statically known (e.g., pre-registered). + * + * @param ctx - Carries the resolved authorization-server `issuer`. Providers + * that persist credentials per authorization server should store the entry + * keyed by `ctx.issuer`. */ - saveClientInformation?(clientInformation: OAuthClientInformationMixed): void | Promise; + saveClientInformation?(clientInformation: StoredOAuthClientInformation, ctx?: OAuthClientInformationContext): void | Promise; /** * Loads any existing OAuth tokens for the current session, or returns * `undefined` if there are no saved tokens. + * + * @param ctx - Carries the resolved authorization-server `issuer`. Providers + * that persist tokens per authorization server should return the entry + * keyed by `ctx.issuer`. Providers with a single token set may ignore it. + * When called with no `ctx` — the transport's per-request bearer-token + * read — return the most-recently-saved token set; do not return + * `undefined` for `ctx === undefined`. */ - tokens(): OAuthTokens | undefined | Promise; + tokens(ctx?: OAuthClientInformationContext): StoredOAuthTokens | undefined | Promise; /** * Stores new OAuth tokens for the current session, after a successful * authorization. + * + * @param ctx - Carries the resolved authorization-server `issuer`. Providers + * that persist tokens per authorization server should store the entry + * keyed by `ctx.issuer`. */ - saveTokens(tokens: OAuthTokens): void | Promise; + saveTokens(tokens: StoredOAuthTokens, ctx?: OAuthClientInformationContext): void | Promise; /** * Invoked to redirect the user agent to the given URL to begin the authorization flow. @@ -284,24 +404,23 @@ export interface OAuthClientProvider { prepareTokenRequest?(scope?: string): URLSearchParams | Promise | undefined; /** - * Saves the authorization server URL after RFC 9728 discovery. - * This method is called by {@linkcode auth} after successful discovery of the - * authorization server via protected resource metadata. + * Saves the resolved authorization-server **issuer**. Called after a successful + * token exchange (timing changed in v2: was post-discovery, now post-`saveTokens`). * - * Providers implementing Cross-App Access or other flows that need access to - * the discovered authorization server URL should implement this method. - * - * @param authorizationServerUrl - The authorization server URL discovered via RFC 9728 + * @deprecated Superseded by the `issuer` stamp on stored tokens / client credentials + * (SEP-2352). {@linkcode auth} still **writes** this for back-compat with providers + * that read it (e.g. Cross-App Access), but the SDK never reads it. Prefer reading + * the `issuer` field on the value passed to {@linkcode saveTokens} / + * {@linkcode saveClientInformation}, or the `ctx.issuer` argument. */ saveAuthorizationServerUrl?(authorizationServerUrl: string): void | Promise; /** * Returns the previously saved authorization server URL, if available. * - * Providers implementing Cross-App Access can use this to access the - * authorization server URL discovered during the OAuth flow. - * - * @returns The authorization server URL, or `undefined` if not available + * @deprecated Superseded by the `issuer` stamp on stored tokens / client credentials + * (SEP-2352). The SDK never reads this method; it remains for provider implementations + * that consume the value internally (e.g. Cross-App Access). */ authorizationServerUrl?(): string | undefined | Promise; @@ -336,6 +455,9 @@ export interface OAuthClientProvider { * external configuration) to bootstrap the OAuth flow without discovery. * * Called by {@linkcode auth} after successful discovery. + * + * MUST persist with the same durability as `codeVerifier` (survives the redirect + * round-trip). */ saveDiscoveryState?(state: OAuthDiscoveryState): void | Promise; @@ -346,9 +468,12 @@ export interface OAuthClientProvider { * URL, resource metadata, etc.) instead of performing RFC 9728 discovery, reducing * latency on subsequent calls. * - * Providers should clear cached discovery state on repeated authentication failures - * (via {@linkcode invalidateCredentials} with scope `'discovery'` or `'all'`) to allow - * re-discovery in case the authorization server has changed. + * Hosts should call {@linkcode invalidateCredentials} with scope `'discovery'` + * on repeated 401s so a changed `authorization_servers` list is picked up; the + * SDK does not invoke that scope itself. + * + * MUST persist with the same durability as `codeVerifier` (survives the redirect + * round-trip). */ discoveryState?(): OAuthDiscoveryState | undefined | Promise; } @@ -376,6 +501,184 @@ export class UnauthorizedError extends Error { } } +/** + * Validates the `iss` parameter from an authorization response against the + * issuer recorded from the authorization server's validated metadata, per + * RFC 9207 §2.4 and the MCP specification's four-row decision table. + * + * | `issParameterSupported` | `iss` | Action | + * | ----------------------- | ------- | ------------------------------------------------ | + * | `true` | present | compare; throw {@linkcode IssuerMismatchError} on mismatch | + * | `true` | absent | throw {@linkcode IssuerMismatchError} | + * | `false` | present | compare; throw {@linkcode IssuerMismatchError} on mismatch | + * | `false` | absent | proceed (no-op) | + * + * Comparison is **simple string equality** (RFC 3986 §6.2.1). Scheme/host case + * folding, default-port elision, trailing-slash, and percent-encoding + * normalization are explicitly **not** applied — any difference is a mismatch. + * + * When `expectedIssuer` is `undefined` (no validated metadata document exists), + * the check has no authentic baseline and degenerates to a no-op. + * + * @throws {IssuerMismatchError} with `kind: 'authorization_response'` + */ +/** + * Reads RFC 9207's `authorization_response_iss_parameter_supported` from + * authorization-server metadata. Only a literal `true` counts as advertised; + * absent, `false`, or a non-boolean wire value (coerced to `undefined` by the + * schema) means not advertised. + */ +function isIssParameterSupported(metadata: AuthorizationServerMetadata | undefined): boolean { + return metadata?.authorization_response_iss_parameter_supported === true; +} + +export function validateAuthorizationResponseIssuer({ + iss, + expectedIssuer, + issParameterSupported +}: { + /** The form-urldecoded `iss` query parameter from the authorization callback, or `undefined` if absent. */ + iss: string | undefined; + /** The `issuer` value from the authorization server's validated metadata document. */ + expectedIssuer: string | undefined; + /** Whether the metadata advertised `authorization_response_iss_parameter_supported: true`. */ + issParameterSupported: boolean; +}): void { + if (expectedIssuer === undefined) { + // No validated metadata document → no recorded issuer → no comparison (table row 4). + return; + } + if (iss === undefined) { + if (issParameterSupported) { + // Row 2: AS advertised that it always sends `iss`; absence is a stripped-parameter attack indicator. + throw new IssuerMismatchError('authorization_response', expectedIssuer, undefined); + } + // Row 4: not advertised, not present → proceed. + return; + } + // Rows 1 & 3: present → compare with simple string comparison only. + if (iss !== expectedIssuer) { + throw new IssuerMismatchError('authorization_response', expectedIssuer, iss); + } +} + +/** + * Computes the union of one or more OAuth `scope` strings. + * + * Each argument is a space-delimited scope string per RFC 6749 §3.3, or + * `undefined`. The result is a single space-delimited string containing each + * distinct scope token exactly once, in first-seen order, or `undefined` if + * every input is empty/undefined. + * + * No hierarchical deduplication is performed: a union may contain semantically + * redundant entries (e.g., a broad scope alongside a narrower one it implies). + * Authorization servers normalize such redundancy during token issuance; the + * spec's step-up flow does not require clients to. + * + * Used by the transport's `403 insufficient_scope` step-up path to accumulate + * previously-requested scopes with newly-challenged scopes so re-authorization + * does not lose previously-granted permissions. + */ +export function computeScopeUnion(...scopes: ReadonlyArray): string | undefined { + const seen = new Set(); + for (const scope of scopes) { + if (!scope) continue; + for (const token of scope.split(/\s+/)) { + if (token) seen.add(token); + } + } + return seen.size > 0 ? [...seen].join(' ') : undefined; +} + +/** + * Whether `union` contains at least one scope token not present in `current`. + * Both arguments are space-delimited scope strings per RFC 6749 §3.3. + * + * Used to gate the step-up refresh bypass: when the union of previously-requested + * and newly-challenged scopes is a strict superset of the current token's + * granted scope, refreshing cannot widen the grant (RFC 6749 §6), so the + * transport must force a fresh authorization request instead. When the current + * token already covers the union, refresh remains valid. + * + * An undefined or empty `current` is treated as the empty set, so any non-empty + * `union` is a strict superset. Note that per RFC 6749 §3.3 an authorization + * server MAY omit the token's `scope` field when it equals the requested scope; + * this helper is conservative and treats an absent token `scope` as empty, so + * step-up always forces a fresh authorization request in that case rather than + * risking a refresh that silently drops the widened scope. + */ +export function isStrictScopeSuperset(union: string | undefined, current: string | undefined): boolean { + if (!union) return false; + const currentSet = new Set((current ?? '').split(/\s+/).filter(Boolean)); + for (const token of union.split(/\s+/)) { + if (token && !currentSet.has(token)) return true; + } + return false; +} + +/** + * Shared `finishAuth` resolver for the `(code, iss?)` and `(URLSearchParams)` overloads. + * + * For the `URLSearchParams` form, only `iss` and `code` are read up front. When a `code` is + * present the returned values flow into {@linkcode auth}, which runs + * {@linkcode validateAuthorizationResponseIssuer} against freshly-discovered metadata before + * the code is redeemed — so on mismatch the thrown {@linkcode IssuerMismatchError} carries no + * `error`/`error_description`/`error_uri` text from the callback (those are attacker-controlled + * in a mix-up). When no `code` is present (an error-shaped callback), `iss` is validated here + * against the provider's recorded discovery state — or, when the provider does not implement + * `discoveryState`, against freshly-discovered metadata mirroring what {@linkcode auth} does on + * the code-present path — **before** the callback's error parameters are read; only after that + * passes are they surfaced as an {@linkcode OAuthError}. When no issuer baseline can be + * obtained either way, a generic {@linkcode UnauthorizedError} is thrown without surfacing the + * callback's `error`/`error_description`/`error_uri`. + * + * @internal Exported for the transport `finishAuth` overloads; not part of the public barrel. + */ +export async function resolveAuthorizationCallbackParams( + codeOrParams: string | URLSearchParams, + iss: string | undefined, + provider: OAuthClientProvider, + serverUrl: string | URL, + opts?: { fetchFn?: FetchLike; resourceMetadataUrl?: URL } +): Promise<{ authorizationCode: string; iss: string | undefined }> { + if (typeof codeOrParams === 'string') { + return { authorizationCode: codeOrParams, iss }; + } + const issParam = codeOrParams.get('iss') ?? undefined; + const code = codeOrParams.get('code'); + if (code) { + return { authorizationCode: code, iss: issParam }; + } + // No code → error response. Gate the (potentially attacker-supplied) error params on the + // issuer first. Prefer the provider's recorded discovery state; when absent, mirror auth()'s + // code-present path and run a fresh discovery so the iss gate has an authentic baseline. + const discoveryState = await provider.discoveryState?.(); + let metadata = discoveryState?.authorizationServerMetadata; + if (!metadata) { + try { + const serverInfo = await discoverOAuthServerInfo(serverUrl, opts); + metadata = serverInfo.authorizationServerMetadata; + } catch { + metadata = undefined; + } + } + if (!metadata) { + // No authentic baseline → cannot prove the error params came from our AS. Do NOT surface + // attacker-controllable `error`/`error_description`/`error_uri` here. + throw new UnauthorizedError('Authorization callback failed and the issuer could not be verified'); + } + validateAuthorizationResponseIssuer({ + iss: issParam, + expectedIssuer: metadata.issuer, + issParameterSupported: isIssParameterSupported(metadata) + }); + const error = codeOrParams.get('error'); + if (error) { + throw new OAuthError(error, codeOrParams.get('error_description') ?? error, codeOrParams.get('error_uri') ?? undefined); + } + throw new UnauthorizedError('Authorization callback contained neither `code` nor `error`'); +} + export type ClientAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none'; function isClientAuthMethod(method: string): method is ClientAuthMethod { @@ -506,6 +809,80 @@ export function applyPublicAuth(clientId: string, params: URLSearchParams): void params.set('client_id', clientId); } +/** Loopback hosts exempt from the in-transit `https:` requirement (RFC 8252 §7.3). */ +function isLoopbackHost(hostname: string): boolean { + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1'; +} + +/** + * SEP-2207: refuse to send credentials to a non-TLS, non-loopback token endpoint. + * Throws {@linkcode InsecureTokenEndpointError}. Loopback hosts are exempt. + */ +export function assertSecureTokenEndpoint(tokenEndpoint: string | URL): URL { + const url = new URL(String(tokenEndpoint)); + if (url.protocol !== 'https:' && !isLoopbackHost(url.hostname)) { + throw new InsecureTokenEndpointError(url.href); + } + return url; +} + +/** + * Derives an OIDC `application_type` from a client's registered redirect URIs + * when the consumer has not set one explicitly (SEP-837). Loopback hosts and + * non-`http(s)` custom URI schemes indicate a native application (RFC 8252); + * everything else is treated as a web application. The result is a heuristic + * default — callers that know better should set `clientMetadata.application_type` + * themselves, which {@linkcode resolveClientMetadata} never overwrites. + * + * A mixed redirect set (for example a public `https:` URI alongside a loopback + * URI) is inherently ambiguous under OIDC DCR §2 — neither value satisfies the + * AS for both URIs — so consumers with mixed sets should set `application_type` + * explicitly rather than relying on this heuristic. + */ +function deriveApplicationType(redirectUris: readonly string[] | undefined): 'native' | 'web' { + for (const raw of redirectUris ?? []) { + let url: URL; + try { + url = new URL(raw); + } catch { + continue; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return 'native'; + if (isLoopbackHost(url.hostname)) return 'native'; + } + return 'web'; +} + +/** + * Reads {@linkcode OAuthClientProvider.clientMetadata | clientMetadata} from the + * provider and fills the SEP-837 / SEP-2207 defaults the SDK relies on, so + * {@linkcode registerClient} sees a consistent, fully-populated document. + * + * - `grant_types` defaults to `['authorization_code', 'refresh_token']` for + * interactive providers (those with a {@linkcode OAuthClientProvider.redirectUrl | redirectUrl}) + * so authorization servers that gate refresh-token issuance on the registered + * grant types issue one (SEP-2207). Non-interactive providers (no + * `redirectUrl`) get no `grant_types` default. This default applies to the + * Dynamic Client Registration body only — it does **not** drive + * {@linkcode determineScope}'s `offline_access` augmentation. + * - `application_type` defaults from `redirect_uris`: loopback redirect hosts + * and custom URI schemes → `'native'`, otherwise `'web'` (SEP-837 / RFC 8252). + * + * A field the consumer set explicitly is **never** overwritten. {@linkcode auth} + * calls this once at the top of the flow; direct callers of + * {@linkcode registerClient} that want the same defaults should pass the result + * of this function as `clientMetadata`. + */ +export function resolveClientMetadata(provider: Pick): OAuthClientMetadata { + const clientMetadata = provider.clientMetadata; + return { + ...clientMetadata, + grant_types: + clientMetadata.grant_types ?? (provider.redirectUrl === undefined ? undefined : ['authorization_code', 'refresh_token']), + application_type: clientMetadata.application_type ?? deriveApplicationType(clientMetadata.redirect_uris) + }; +} + /** * Parses an OAuth error response from a string or Response object. * @@ -531,29 +908,80 @@ export async function parseErrorResponse(input: Response | string): Promise { +export async function auth(provider: OAuthClientProvider, options: AuthOptions): Promise { try { return await authInternal(provider, options); } catch (error) { // Handle recoverable error types by invalidating credentials and retrying if (error instanceof OAuthError) { if (error.code === OAuthErrorCode.InvalidClient || error.code === OAuthErrorCode.UnauthorizedClient) { - await provider.invalidateCredentials?.('all'); + // Not 'all' — preserve discoveryState so the callback-leg gate on retry doesn't + // fire a false 'discoveryState was not available on the callback leg' AuthorizationServerMismatchError that masks the + // real invalid_client. + await provider.invalidateCredentials?.('client'); + await provider.invalidateCredentials?.('tokens'); return await authInternal(provider, options); } else if (error.code === OAuthErrorCode.InvalidGrant) { await provider.invalidateCredentials?.('tokens'); @@ -584,8 +1012,10 @@ export function determineScope(options: { // 4. Omit scope parameter let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope; - // SEP-2207: Append offline_access when the AS advertises it - // and the client supports the refresh_token grant. + // SEP-2207: Append offline_access when the AS advertises it and the client + // supports the refresh_token grant. Gated on consumer-supplied grant_types; + // SDK DCR default intentionally NOT applied here so statically-registered/CIMD + // clients are not pushed into offline_access + prompt=consent. if ( effectiveScope && authServerMetadata?.scopes_supported?.includes('offline_access') && @@ -603,23 +1033,25 @@ async function authInternal( { serverUrl, authorizationCode, + iss, scope, resourceMetadataUrl, - fetchFn - }: { - serverUrl: string | URL; - authorizationCode?: string; - scope?: string; - resourceMetadataUrl?: URL; - fetchFn?: FetchLike; - } + fetchFn, + skipIssuerMetadataValidation, + forceReauthorization + }: AuthOptions ): Promise { + // SEP-837 / SEP-2207: resolve spec defaults for the DCR body. determineScope() + // intentionally reads the raw provider.clientMetadata instead. + const clientMetadata = resolveClientMetadata(provider); + // Check if the provider has cached discovery state to skip discovery const cachedState = await provider.discoveryState?.(); let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl: string | URL; let metadata: AuthorizationServerMetadata | undefined; + let freshDiscoveryState: OAuthDiscoveryState | undefined; // If resourceMetadataUrl is not provided, try to load it from cached state // This handles browser redirects where the URL was saved before navigation @@ -633,7 +1065,11 @@ async function authInternal( authorizationServerUrl = cachedState.authorizationServerUrl; resourceMetadata = cachedState.resourceMetadata; metadata = - cachedState.authorizationServerMetadata ?? (await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn })); + cachedState.authorizationServerMetadata ?? + (await discoverAuthorizationServerMetadata(authorizationServerUrl, { + fetchFn, + skipIssuerValidation: skipIssuerMetadataValidation + })); // If resource metadata wasn't cached, try to fetch it for selectResourceURL if (!resourceMetadata) { @@ -664,25 +1100,70 @@ async function authInternal( } } else { // Full discovery via RFC 9728 - const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn }); + const serverInfo = await discoverOAuthServerInfo(serverUrl, { + resourceMetadataUrl: effectiveResourceMetadataUrl, + fetchFn, + skipIssuerMetadataValidation + }); authorizationServerUrl = serverInfo.authorizationServerUrl; metadata = serverInfo.authorizationServerMetadata; resourceMetadata = serverInfo.resourceMetadata; - // Persist discovery state for future use + // Captured now, persisted only after the SEP-2352 callback-leg gate below — so a + // gate throw cannot leave a freshly resolved (potentially PRM-poisoned) AS recorded + // for the retry to read back as `recordedIssuer`. // TODO: resourceMetadataUrl is only populated when explicitly provided via options // or loaded from cached state. The URL derived internally by // discoverOAuthProtectedResourceMetadata() is not captured back here. - await provider.saveDiscoveryState?.({ + freshDiscoveryState = { authorizationServerUrl: String(authorizationServerUrl), resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), resourceMetadata, authorizationServerMetadata: metadata - }); + }; + } + + // SEP-2352: the canonical authorization-server identity for this flow. `metadata.issuer` + // is RFC 8414 §3.3-validated to equal the discovery URL; when no metadata document was + // found (legacy fallback) the discovery URL itself is the only identifier available. + const issuer = metadata?.issuer ?? String(authorizationServerUrl); + const infoCtx: OAuthClientInformationContext = { issuer }; + + // Deprecated write-only hook, kept for providers (e.g. Cross-App Access) that read it + // internally. The SDK never reads `authorizationServerUrl()`. + await provider.saveAuthorizationServerUrl?.(issuer); + + // SEP-2352 callback-leg gate. Stored credentials are protected structurally by the + // issuer stamp, but the in-flight `authorization_code` + PKCE `code_verifier` are not + // stored — they are bound to the AS the redirect targeted, recorded in `discoveryState()`. + // Fail-closed: a provider that implements saveDiscoveryState but returned no discovery + // state on the callback leg (e.g. not persisted alongside codeVerifier across page navigation) MUST NOT + // proceed — fresh discovery may have resolved a different AS than the one the user + // approved at /authorize, and the clientInformation stamp alone does not protect a keyed + // multi-AS provider here. Providers that do not implement saveDiscoveryState at all keep + // the (legacy) warn-and-proceed behavior. + if (authorizationCode !== undefined) { + const recordedIssuer = cachedState?.authorizationServerMetadata?.issuer ?? cachedState?.authorizationServerUrl; + if (recordedIssuer === undefined) { + if (provider.saveDiscoveryState !== undefined) { + throw new AuthorizationServerMismatchError( + 'discoveryState was not available on the callback leg; ensure your provider persists discoveryState alongside codeVerifier', + issuer + ); + } + console.warn( + '[mcp-sdk] OAuthClientProvider does not implement saveDiscoveryState()/discoveryState(); ' + + 'the SEP-2352 callback-leg authorization-server binding cannot be checked. ' + + 'Implement discoveryState (persist alongside codeVerifier) — see docs/migration/upgrade-to-v2.md §SEP-2352.' + ); + } else if (!issuersMatch(recordedIssuer, issuer)) { + throw new AuthorizationServerMismatchError(recordedIssuer, issuer); + } } - // Save authorization server URL for providers that need it (e.g., CrossAppAccessProvider) - await provider.saveAuthorizationServerUrl?.(String(authorizationServerUrl)); + if (freshDiscoveryState) { + await provider.saveDiscoveryState?.(freshDiscoveryState); + } const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); @@ -699,8 +1180,26 @@ async function authInternal( clientMetadata: provider.clientMetadata }); - // Handle client registration if needed - let clientInformation = await Promise.resolve(provider.clientInformation()); + // Handle client registration if needed. SEP-2352: a stored credential whose `issuer` + // stamp names a different authorization server reads back as `undefined`, so the flow + // re-registers exactly as if nothing were stored. + const rawClientInfo = await Promise.resolve(provider.clientInformation(infoCtx)); + let clientInformation = discardIfIssuerMismatch(rawClientInfo, issuer, { + canPersistStamp: provider.saveClientInformation !== undefined + }); + if (clientInformation === undefined && rawClientInfo?.issuer && provider.saveClientInformation === undefined) { + // Static-credential provider (no DCR) whose `expectedIssuer` stamp names a different + // AS — surface the typed error with both issuers rather than the generic + // "client information must be saveable for dynamic registration" fallback. + throw new AuthorizationServerMismatchError(rawClientInfo.issuer, issuer); + } + if (clientInformation && clientInformation.issuer === undefined) { + // SEP-2352 back-stamp: legacy (pre-SEP-2352) storage returned an unstamped value. + // Bind it to the first AS resolved after upgrade so subsequent calls have a real + // stamp to compare against — closes the otherwise-permanent unstamped window. + clientInformation = { ...clientInformation, issuer }; + await provider.saveClientInformation?.(clientInformation, infoCtx); + } if (!clientInformation) { if (authorizationCode !== undefined) { throw new Error('Existing OAuth client information is required when exchanging an authorization code'); @@ -720,10 +1219,8 @@ async function authInternal( if (shouldUseUrlBasedClientId) { // SEP-991: URL-based Client IDs - clientInformation = { - client_id: clientMetadataUrl - }; - await provider.saveClientInformation?.(clientInformation); + clientInformation = { client_id: clientMetadataUrl, issuer }; + await provider.saveClientInformation?.(clientInformation, infoCtx); } else { // Fallback to dynamic registration if (!provider.saveClientInformation) { @@ -732,13 +1229,13 @@ async function authInternal( const fullInformation = await registerClient(authorizationServerUrl, { metadata, - clientMetadata: provider.clientMetadata, + clientMetadata, scope: resolvedScope, fetchFn }); - await provider.saveClientInformation(fullInformation); - clientInformation = fullInformation; + clientInformation = { ...fullInformation, issuer }; + await provider.saveClientInformation(clientInformation, infoCtx); } } @@ -747,22 +1244,45 @@ async function authInternal( // Exchange authorization code for tokens, or fetch tokens directly for non-interactive flows if (authorizationCode !== undefined || nonInteractiveFlow) { + // RFC 9207: validate the callback `iss` against the recorded issuer before the + // authorization code is sent to any token endpoint. Non-interactive flows have no + // authorization response, so the gate is keyed on `authorizationCode`. + if (authorizationCode !== undefined) { + validateAuthorizationResponseIssuer({ + iss, + expectedIssuer: metadata?.issuer, + issParameterSupported: isIssParameterSupported(metadata) + }); + } + const tokens = await fetchToken(provider, authorizationServerUrl, { metadata, resource, authorizationCode, + iss, scope: resolvedScope, fetchFn }); - await provider.saveTokens(tokens); + await provider.saveTokens({ ...tokens, issuer }, infoCtx); return 'AUTHORIZED'; } - const tokens = await provider.tokens(); + // SEP-2352: a refresh_token stamped for a different authorization server reads back + // as `undefined`, so it is never POSTed to this AS's token endpoint. + let tokens = discardIfIssuerMismatch(await provider.tokens(infoCtx), issuer); + if (tokens && tokens.issuer === undefined) { + // SEP-2352 back-stamp: bind a legacy unstamped token set to the first-resolved AS + // so the stamp check is effective from the next call onward. + tokens = { ...tokens, issuer }; + await provider.saveTokens(tokens, infoCtx); + } - // Handle token refresh or new authorization - if (tokens?.refresh_token) { + // Handle token refresh or new authorization. The step-up path sets + // `forceReauthorization` when the requested scope strictly exceeds the + // current token's granted scope — refreshing would not widen it (RFC 6749 + // §6), so skip straight to a fresh authorization request. + if (tokens?.refresh_token && !forceReauthorization) { try { // Attempt to refresh the token const newTokens = await refreshAuthorization(authorizationServerUrl, { @@ -774,9 +1294,15 @@ async function authInternal( fetchFn }); - await provider.saveTokens(newTokens); + await provider.saveTokens({ ...newTokens, issuer }, infoCtx); return 'AUTHORIZED'; } catch (error) { + // A non-TLS token endpoint is a configuration error — re-authorizing cannot + // fix it. Surface it so the consumer sees the misconfiguration instead of an + // unexplained re-auth prompt. + if (error instanceof InsecureTokenEndpointError) { + throw error; + } // If this is a ServerError, or an unknown type, log it out and try to continue. Otherwise, escalate so we can fix things and retry. if (!(error instanceof OAuthError) || error.code === OAuthErrorCode.ServerError) { // Could not refresh OAuth tokens @@ -866,9 +1392,15 @@ export async function selectResourceURL( } /** - * Extract `resource_metadata`, `scope`, and `error` from `WWW-Authenticate` header. + * Extract `resource_metadata`, `scope`, `error`, and `error_description` from a + * `WWW-Authenticate` header. */ -export function extractWWWAuthenticateParams(res: Response): { resourceMetadataUrl?: URL; scope?: string; error?: string } { +export function extractWWWAuthenticateParams(res: Response): { + resourceMetadataUrl?: URL; + scope?: string; + error?: string; + errorDescription?: string; +} { const authenticateHeader = res.headers.get('WWW-Authenticate'); if (!authenticateHeader) { return {}; @@ -892,11 +1424,13 @@ export function extractWWWAuthenticateParams(res: Response): { resourceMetadataU const scope = extractFieldFromWwwAuth(res, 'scope') || undefined; const error = extractFieldFromWwwAuth(res, 'error') || undefined; + const errorDescription = extractFieldFromWwwAuth(res, 'error_description') || undefined; return { resourceMetadataUrl, scope, - error + error, + errorDescription }; } @@ -1217,19 +1751,29 @@ export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { url: * @param authorizationServerUrl - The authorization server URL obtained from the MCP Server's * protected resource metadata, or the MCP server's URL if the * metadata was not found. + * The returned metadata's `issuer` is validated against `authorizationServerUrl` + * per RFC 8414 §3.3 (and OIDC Discovery §4.3): if they differ the metadata is + * **rejected** with {@linkcode IssuerMismatchError} and not returned. Set + * `skipIssuerValidation: true` to suppress this check — **security-weakening**, + * intended only for known-misconfigured authorization servers. + * * @param options - Configuration options * @param options.fetchFn - Optional fetch function for making HTTP requests, defaults to global fetch * @param options.protocolVersion - MCP protocol version to use, defaults to {@linkcode LATEST_PROTOCOL_VERSION} + * @param options.skipIssuerValidation - Skip the RFC 8414 §3.3 `issuer` echo check. **Security-weakening.** * @returns Promise resolving to authorization server metadata, or undefined if discovery fails + * @throws {IssuerMismatchError} when the metadata's `issuer` does not match `authorizationServerUrl` */ export async function discoverAuthorizationServerMetadata( authorizationServerUrl: string | URL, { fetchFn = fetch, - protocolVersion = LATEST_PROTOCOL_VERSION + protocolVersion = LATEST_PROTOCOL_VERSION, + skipIssuerValidation = false }: { fetchFn?: FetchLike; protocolVersion?: string; + skipIssuerValidation?: boolean; } = {} ): Promise { const headers = { @@ -1263,9 +1807,29 @@ export async function discoverAuthorizationServerMetadata( } // Parse and validate based on type - return type === 'oauth' - ? OAuthMetadataSchema.parse(await response.json()) - : OpenIdProviderDiscoveryMetadataSchema.parse(await response.json()); + const parsed = + type === 'oauth' + ? OAuthMetadataSchema.parse(await response.json()) + : OpenIdProviderDiscoveryMetadataSchema.parse(await response.json()); + + if (!skipIssuerValidation) { + // RFC 8414 §3.3 / OIDC Discovery §4.3: the `issuer` value in the document MUST be + // identical to the issuer identifier used to construct the well-known URL. Compare + // against the raw input string — callers pass the exact issuer string the AS published. + const expectedIssuer = typeof authorizationServerUrl === 'string' ? authorizationServerUrl : authorizationServerUrl.href; + // One narrow tolerance: the SDK's own legacy-fallback path synthesizes the AS URL via + // `String(new URL('/', serverUrl))`, which always carries a trailing `/`. That value is + // SDK-generated (not attacker-controlled), so accept the slash-only difference here. + // The tolerance is one-directional and end-anchored — a different host or path is still + // a mismatch. + const matches = + parsed.issuer === expectedIssuer || (expectedIssuer.endsWith('/') && parsed.issuer === expectedIssuer.slice(0, -1)); + if (!matches) { + throw new IssuerMismatchError('metadata', expectedIssuer, parsed.issuer); + } + } + + return parsed; } return undefined; @@ -1319,6 +1883,11 @@ export async function discoverOAuthServerInfo( opts?: { resourceMetadataUrl?: URL; fetchFn?: FetchLike; + /** + * Forwarded to {@linkcode discoverAuthorizationServerMetadata} as + * `skipIssuerValidation`. **Security-weakening** — see {@linkcode AuthOptions.skipIssuerMetadataValidation}. + */ + skipIssuerMetadataValidation?: boolean; } ): Promise { let resourceMetadata: OAuthProtectedResourceMetadata | undefined; @@ -1349,7 +1918,10 @@ export async function discoverOAuthServerInfo( authorizationServerUrl = String(new URL('/', serverUrl)); } - const authorizationServerMetadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn: opts?.fetchFn }); + const authorizationServerMetadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { + fetchFn: opts?.fetchFn, + skipIssuerValidation: opts?.skipIssuerMetadataValidation + }); return { authorizationServerUrl, @@ -1476,7 +2048,7 @@ export async function executeTokenRequest( fetchFn?: FetchLike; } ): Promise { - const tokenUrl = metadata?.token_endpoint ? new URL(metadata.token_endpoint) : new URL('/token', authorizationServerUrl); + const tokenUrl = assertSecureTokenEndpoint(metadata?.token_endpoint ?? new URL('/token', authorizationServerUrl)); const headers = new Headers({ 'Content-Type': 'application/x-www-form-urlencoded', @@ -1537,6 +2109,7 @@ export async function exchangeAuthorization( metadata, clientInformation, authorizationCode, + iss, codeVerifier, redirectUri, resource, @@ -1546,6 +2119,12 @@ export async function exchangeAuthorization( metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformationMixed; authorizationCode: string; + /** + * The form-urldecoded `iss` query parameter from the authorization callback. + * Validated per RFC 9207 §2.4 against `metadata.issuer` before the code is + * redeemed; see {@linkcode validateAuthorizationResponseIssuer}. + */ + iss?: string; codeVerifier: string; redirectUri: string | URL; resource?: URL; @@ -1553,6 +2132,12 @@ export async function exchangeAuthorization( fetchFn?: FetchLike; } ): Promise { + validateAuthorizationResponseIssuer({ + iss, + expectedIssuer: metadata?.issuer, + issParameterSupported: isIssParameterSupported(metadata) + }); + const tokenRequestParams = prepareAuthorizationCodeRequest(authorizationCode, codeVerifier, redirectUri); return executeTokenRequest(authorizationServerUrl, { @@ -1647,6 +2232,7 @@ export async function fetchToken( metadata, resource, authorizationCode, + iss, scope, fetchFn }: { @@ -1654,11 +2240,25 @@ export async function fetchToken( resource?: URL; /** Authorization code for the default `authorization_code` grant flow */ authorizationCode?: string; + /** + * The form-urldecoded `iss` query parameter from the authorization callback. + * Validated per RFC 9207 §2.4 when `authorizationCode` is present; + * see {@linkcode validateAuthorizationResponseIssuer}. + */ + iss?: string; /** Optional scope parameter from auth() options */ scope?: string; fetchFn?: FetchLike; } = {} ): Promise { + if (authorizationCode !== undefined) { + validateAuthorizationResponseIssuer({ + iss, + expectedIssuer: metadata?.issuer, + issParameterSupported: isIssParameterSupported(metadata) + }); + } + // Prefer scope from options, fallback to provider.clientMetadata.scope const effectiveScope = scope ?? provider.clientMetadata.scope; @@ -1680,7 +2280,7 @@ export async function fetchToken( tokenRequestParams = prepareAuthorizationCodeRequest(authorizationCode, codeVerifier, provider.redirectUrl); } - const clientInformation = await provider.clientInformation(); + const clientInformation = await provider.clientInformation({ issuer: metadata?.issuer ?? String(authorizationServerUrl) }); return executeTokenRequest(authorizationServerUrl, { metadata, @@ -1699,6 +2299,12 @@ export async function fetchToken( * If `scope` is provided, it overrides `clientMetadata.scope` in the registration * request body. This allows callers to apply the Scope Selection Strategy (SEP-835) * consistently across both DCR and the subsequent authorization request. + * + * @deprecated Dynamic Client Registration is deprecated as of protocol version + * 2026-07-28 (SEP-2577) in favor of Client ID Metadata Documents (SEP-991). + * Remains functional during the deprecation window (at least twelve months). + * Prefer a CIMD URL `client_id` when the authorization server advertises + * `client_id_metadata_document_supported`; the SDK already gates on this for you. */ export async function registerClient( authorizationServerUrl: string | URL, @@ -1726,19 +2332,24 @@ export async function registerClient( registrationUrl = new URL('/register', authorizationServerUrl); } + // `clientMetadata` arrives via resolveClientMetadata() inside auth(), so the + // SEP-837/2207 defaults are already applied. Direct callers that want the + // same defaults should pass resolveClientMetadata(provider) here. + const submittedMetadata: OAuthClientMetadata = { + ...clientMetadata, + ...(scope === undefined ? {} : { scope }) + }; + const response = await (fetchFn ?? fetch)(registrationUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ...clientMetadata, - ...(scope === undefined ? {} : { scope }) - }) + body: JSON.stringify(submittedMetadata) }); if (!response.ok) { - throw await parseErrorResponse(response); + throw new RegistrationRejectedError({ status: response.status, body: await response.text(), submittedMetadata }); } return OAuthClientInformationFullSchema.parse(await response.json()); diff --git a/packages/client/src/client/authErrors.ts b/packages/client/src/client/authErrors.ts new file mode 100644 index 0000000000..4ced2d6f0d --- /dev/null +++ b/packages/client/src/client/authErrors.ts @@ -0,0 +1,176 @@ +/** + * Error classes thrown by the OAuth client flow ({@linkcode auth} and helpers). + * + * Each behavior change in the 2026-07-28 authorization requirements adds its + * dedicated error class to this module so callers can `instanceof`-dispatch on + * the failure mode without string-matching messages. + */ + +import type { OAuthClientMetadata } from '@modelcontextprotocol/core'; + +/** + * Base class for the OAuth-client-flow error family. Concrete subclasses are + * added to this module alongside the SEP-2468/837/2207/2350/2352 behavior + * changes that throw them, so callers can catch the whole family with a single + * `instanceof OAuthClientFlowError` guard once those land. + * + * @remarks Nothing in the SDK throws this base class directly. In the release + * that introduces it no subclass exists yet — the guard is a forward-compat + * hook and will not match anything until the first behavior change ships. + */ +export class OAuthClientFlowError extends Error { + constructor(message: string) { + super(message); + this.name = new.target.name; + } +} + +/** + * Thrown when an authorization-server issuer identifier fails validation. + * + * Two checks raise this error, distinguished by {@linkcode IssuerMismatchError.kind | kind}: + * - `'metadata'` — the `issuer` in fetched authorization-server metadata does + * not match the issuer identifier the well-known URL was constructed from + * (RFC 8414 §3.3 / OpenID Connect Discovery §4.3). + * - `'authorization_response'` — the `iss` parameter on the authorization + * callback failed RFC 9207 §2.4 validation against the recorded issuer. + * + * Intentionally does **not** extend `OAuthError`: the `auth()` + * orchestrator's `OAuthError` retry block must not swallow this — a mix-up + * indication is fatal for the flow, not a retryable credential problem. + * + * On the `'authorization_response'` path the {@linkcode IssuerMismatchError.received | received} + * value is attacker-controllable in a mix-up attack; callers **MUST NOT** display + * it (or any `error`/`error_description`/`error_uri` from the same callback) to + * end users. The values are JSON-encoded in the message to neutralize log-injection. + */ +export class IssuerMismatchError extends OAuthClientFlowError { + /** Which check failed — metadata echo (RFC 8414 §3.3) or authorization-response `iss` (RFC 9207). */ + readonly kind: 'metadata' | 'authorization_response'; + /** The issuer the client expected (from validated metadata / discovery input). */ + readonly expected: string | undefined; + /** The issuer value that was received. Attacker-controllable on the `'authorization_response'` path. */ + readonly received: string | undefined; + + constructor(kind: 'metadata' | 'authorization_response', expected: string | undefined, received: string | undefined) { + const where = kind === 'metadata' ? 'authorization server metadata (RFC 8414 §3.3)' : 'authorization response (RFC 9207)'; + // JSON-stringify embedded values so attacker-supplied control characters cannot forge log lines. + super(`Issuer mismatch in ${where}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(received)}`); + this.kind = kind; + this.expected = expected; + this.received = received; + } +} + +/** + * Thrown by `registerClient()` when the authorization server rejects a + * Dynamic Client Registration request. Carries the HTTP status, the raw + * response body, and the metadata that was submitted, so callers can inspect + * the AS's `error` / `error_description` and retry with adjusted metadata + * (for example a different `application_type`) per SEP-837. + * + * The `body` is the raw RFC 7591 error JSON; compare `JSON.parse(body).error` + * against `OAuthErrorCode` (e.g. `OAuthErrorCode.InvalidRedirectUri`, + * `OAuthErrorCode.InvalidClientMetadata`). + * + * Intentionally does **not** extend `OAuthError`: registration rejection is + * not a recoverable-by-credential-invalidation condition, and staying outside + * that hierarchy keeps it from being caught by `auth()`'s `OAuthError` retry + * path. + */ +export class RegistrationRejectedError extends OAuthClientFlowError { + /** HTTP status code returned by the registration endpoint. */ + public readonly status: number; + /** Raw response body text (typically an RFC 7591 error JSON document). */ + public readonly body: string; + /** The exact client metadata that was POSTed (after SDK defaults were applied). */ + public readonly submittedMetadata: OAuthClientMetadata; + + constructor(args: { status: number; body: string; submittedMetadata: OAuthClientMetadata }) { + super(`Dynamic Client Registration rejected (HTTP ${args.status}): ${args.body}`); + this.status = args.status; + this.body = args.body; + this.submittedMetadata = args.submittedMetadata; + } +} + +/** + * Thrown by the token-exchange and refresh paths when the resolved token + * endpoint is not `https:` and is not a loopback host (SEP-2207). This is a + * configuration error — re-authorizing cannot fix it — so it intentionally does + * **not** extend `OAuthError` and `auth()`'s refresh branch rethrows it instead + * of falling through to a fresh `/authorize` redirect. + */ +export class InsecureTokenEndpointError extends OAuthClientFlowError { + /** The token endpoint URL that was rejected. */ + public readonly tokenEndpoint: string; + + constructor(tokenEndpoint: string) { + super( + `Refusing to send credentials to non-https token endpoint '${tokenEndpoint}'. ` + + `OAuth token requests MUST use TLS (localhost / 127.0.0.1 / ::1 are exempt).` + ); + this.tokenEndpoint = tokenEndpoint; + } +} + +/** + * Thrown by the HTTP client transport when the server responds with + * `403 Forbidden` and `WWW-Authenticate: Bearer error="insufficient_scope"`, + * and either (a) the transport's `onInsufficientScope` option is `'throw'`, or + * (b) `onInsufficientScope` is the default `'reauthorize'` but the transport + * has no {@linkcode index.OAuthClientProvider | OAuthClientProvider} to drive + * step-up (e.g. a minimal `AuthProvider`, `requestInit`-only headers, or no + * `authProvider`). + * + * Carries the challenge parameters so the host can decide whether to initiate + * step-up authorization itself (e.g., behind a UX gate) or surface the error. + * + * Does **not** extend `OAuthError`: that class represents OAuth protocol errors + * from the authorization server; this is a resource-server challenge surfaced + * at the transport layer. + * + * All fields originate from the resource server's `WWW-Authenticate` header; + * treat them as untrusted input when displaying or logging (this includes + * `requiredScope`, which appears in the error message). + */ +/** + * Thrown by `auth()` on the authorization-code callback leg when the + * authorization server resolved by discovery differs from the one recorded in + * `discoveryState()` at redirect time. The `authorization_code` and PKCE + * `code_verifier` are bound to the AS that minted the code (RFC 7636); sending + * them to a different AS's token endpoint is a credential-exfiltration vector. + * + * This is the only runtime check left in the SEP-2352 model — stored tokens and + * client credentials are protected structurally by the `issuer` stamp instead. + */ +export class AuthorizationServerMismatchError extends OAuthClientFlowError { + constructor( + /** The issuer recorded in `discoveryState()` when the authorization redirect was issued. */ + public readonly recordedIssuer: string, + /** The issuer resolved by discovery on this call. */ + public readonly currentIssuer: string + ) { + super( + `Authorization server changed between redirect and callback ` + + `(redirected to ${JSON.stringify(recordedIssuer)}, callback resolved ${JSON.stringify(currentIssuer)}); ` + + `refusing to send authorization_code/code_verifier to a different token endpoint` + ); + } +} + +export class InsufficientScopeError extends OAuthClientFlowError { + /** The `scope` value from the `WWW-Authenticate` challenge — the scopes the resource server says are required. */ + readonly requiredScope?: string; + /** The `resource_metadata` URL from the `WWW-Authenticate` challenge, if present. */ + readonly resourceMetadataUrl?: URL; + /** The `error_description` from the `WWW-Authenticate` challenge, if present. */ + readonly errorDescription?: string; + + constructor(init: { requiredScope?: string; resourceMetadataUrl?: URL; errorDescription?: string }) { + super(`Insufficient scope${init.requiredScope ? `: required "${init.requiredScope}"` : ''}`); + this.requiredScope = init.requiredScope; + this.resourceMetadataUrl = init.resourceMetadataUrl; + this.errorDescription = init.errorDescription; + } +} diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index cb476c12fd..74cec964ae 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -5,7 +5,7 @@ * for common machine-to-machine authentication scenarios. */ -import type { FetchLike, OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/core'; +import type { FetchLike, OAuthClientMetadata, StoredOAuthClientInformation, StoredOAuthTokens } from '@modelcontextprotocol/core'; import type { CryptoKey, JWK } from 'jose'; import type { AddClientAuthentication, OAuthClientProvider } from './auth.js'; @@ -117,6 +117,15 @@ export interface ClientCredentialsProviderOptions { * Space-separated scopes values requested by the client. */ scope?: string; + + /** + * The authorization server's `issuer` identifier these credentials were registered with. + * Stamped onto the stored client information so `auth()`'s SEP-2352 issuer check + * refuses to send the credential to any other authorization server. Hosts supplying + * static client credentials SHOULD set this; omitting it preserves the legacy + * (no-binding) behaviour for back-compat. + */ + expectedIssuer?: string; } /** @@ -138,14 +147,15 @@ export interface ClientCredentialsProviderOptions { * ``` */ export class ClientCredentialsProvider implements OAuthClientProvider { - private _tokens?: OAuthTokens; - private _clientInfo: OAuthClientInformation; + private _tokens?: StoredOAuthTokens; + private _clientInfo: StoredOAuthClientInformation; private _clientMetadata: OAuthClientMetadata; constructor(options: ClientCredentialsProviderOptions) { this._clientInfo = { client_id: options.clientId, - client_secret: options.clientSecret + client_secret: options.clientSecret, + issuer: options.expectedIssuer }; this._clientMetadata = { client_name: options.clientName ?? 'client-credentials-client', @@ -164,19 +174,20 @@ export class ClientCredentialsProvider implements OAuthClientProvider { return this._clientMetadata; } - clientInformation(): OAuthClientInformation { + clientInformation(): StoredOAuthClientInformation { return this._clientInfo; } - saveClientInformation(info: OAuthClientInformation): void { - this._clientInfo = info; - } + // No saveClientInformation: credentials are constructor-supplied and bound to a single + // authorization server. When `expectedIssuer` is set and the resolved AS differs, the + // SEP-2352 stamp check discards `clientInformation()` and auth() throws + // AuthorizationServerMismatchError(expectedIssuer, resolved) rather than sending the credential. - tokens(): OAuthTokens | undefined { + tokens(): StoredOAuthTokens | undefined { return this._tokens; } - saveTokens(tokens: OAuthTokens): void { + saveTokens(tokens: StoredOAuthTokens): void { this._tokens = tokens; } @@ -243,6 +254,12 @@ export interface PrivateKeyJwtProviderOptions { * with finer granularity than what scopes alone allow. */ claims?: Record; + + /** + * The authorization server's `issuer` identifier these credentials were registered with. + * Seeds the SEP-2352 issuer stamp — see {@linkcode ClientCredentialsProviderOptions.expectedIssuer}. + */ + expectedIssuer?: string; } /** @@ -266,14 +283,15 @@ export interface PrivateKeyJwtProviderOptions { * ``` */ export class PrivateKeyJwtProvider implements OAuthClientProvider { - private _tokens?: OAuthTokens; - private _clientInfo: OAuthClientInformation; + private _tokens?: StoredOAuthTokens; + private _clientInfo: StoredOAuthClientInformation; private _clientMetadata: OAuthClientMetadata; addClientAuthentication: AddClientAuthentication; constructor(options: PrivateKeyJwtProviderOptions) { this._clientInfo = { - client_id: options.clientId + client_id: options.clientId, + issuer: options.expectedIssuer }; this._clientMetadata = { client_name: options.clientName ?? 'private-key-jwt-client', @@ -300,19 +318,19 @@ export class PrivateKeyJwtProvider implements OAuthClientProvider { return this._clientMetadata; } - clientInformation(): OAuthClientInformation { + clientInformation(): StoredOAuthClientInformation { return this._clientInfo; } - saveClientInformation(info: OAuthClientInformation): void { - this._clientInfo = info; - } + // No saveClientInformation: credentials are constructor-supplied; the SEP-2352 stamp + // check enforces `expectedIssuer` and auth() throws + // AuthorizationServerMismatchError(expectedIssuer, resolved) on mismatch. - tokens(): OAuthTokens | undefined { + tokens(): StoredOAuthTokens | undefined { return this._tokens; } - saveTokens(tokens: OAuthTokens): void { + saveTokens(tokens: StoredOAuthTokens): void { this._tokens = tokens; } @@ -361,6 +379,12 @@ export interface StaticPrivateKeyJwtProviderOptions { * Space-separated scopes values requested by the client. */ scope?: string; + + /** + * The authorization server's `issuer` identifier this assertion was minted for. + * Seeds the SEP-2352 issuer stamp — see {@linkcode ClientCredentialsProviderOptions.expectedIssuer}. + */ + expectedIssuer?: string; } /** @@ -371,14 +395,15 @@ export interface StaticPrivateKeyJwtProviderOptions { * uses it directly for authentication. */ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider { - private _tokens?: OAuthTokens; - private _clientInfo: OAuthClientInformation; + private _tokens?: StoredOAuthTokens; + private _clientInfo: StoredOAuthClientInformation; private _clientMetadata: OAuthClientMetadata; addClientAuthentication: AddClientAuthentication; constructor(options: StaticPrivateKeyJwtProviderOptions) { this._clientInfo = { - client_id: options.clientId + client_id: options.clientId, + issuer: options.expectedIssuer }; this._clientMetadata = { client_name: options.clientName ?? 'static-private-key-jwt-client', @@ -403,19 +428,19 @@ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider { return this._clientMetadata; } - clientInformation(): OAuthClientInformation { + clientInformation(): StoredOAuthClientInformation { return this._clientInfo; } - saveClientInformation(info: OAuthClientInformation): void { - this._clientInfo = info; - } + // No saveClientInformation: credentials are constructor-supplied; the SEP-2352 stamp + // check enforces `expectedIssuer` and auth() throws + // AuthorizationServerMismatchError(expectedIssuer, resolved) on mismatch. - tokens(): OAuthTokens | undefined { + tokens(): StoredOAuthTokens | undefined { return this._tokens; } - saveTokens(tokens: OAuthTokens): void { + saveTokens(tokens: StoredOAuthTokens): void { this._tokens = tokens; } @@ -527,6 +552,12 @@ export interface CrossAppAccessProviderOptions { * Custom fetch implementation. Defaults to global fetch. */ fetchFn?: FetchLike; + + /** + * The MCP authorization server's `issuer` identifier these credentials were registered with. + * Seeds the SEP-2352 issuer stamp — see {@linkcode ClientCredentialsProviderOptions.expectedIssuer}. + */ + expectedIssuer?: string; } /** @@ -569,8 +600,8 @@ export interface CrossAppAccessProviderOptions { * ``` */ export class CrossAppAccessProvider implements OAuthClientProvider { - private _tokens?: OAuthTokens; - private _clientInfo: OAuthClientInformation; + private _tokens?: StoredOAuthTokens; + private _clientInfo: StoredOAuthClientInformation; private _clientMetadata: OAuthClientMetadata; private _assertionCallback: AssertionCallback; private _fetchFn: FetchLike; @@ -581,7 +612,8 @@ export class CrossAppAccessProvider implements OAuthClientProvider { constructor(options: CrossAppAccessProviderOptions) { this._clientInfo = { client_id: options.clientId, - client_secret: options.clientSecret + client_secret: options.clientSecret, + issuer: options.expectedIssuer }; this._clientMetadata = { client_name: options.clientName ?? 'cross-app-access-client', @@ -601,19 +633,19 @@ export class CrossAppAccessProvider implements OAuthClientProvider { return this._clientMetadata; } - clientInformation(): OAuthClientInformation { + clientInformation(): StoredOAuthClientInformation { return this._clientInfo; } - saveClientInformation(info: OAuthClientInformation): void { - this._clientInfo = info; - } + // No saveClientInformation: credentials are constructor-supplied; the SEP-2352 stamp + // check enforces `expectedIssuer` and auth() throws + // AuthorizationServerMismatchError(expectedIssuer, resolved) on mismatch. - tokens(): OAuthTokens | undefined { + tokens(): StoredOAuthTokens | undefined { return this._tokens; } - saveTokens(tokens: OAuthTokens): void { + saveTokens(tokens: StoredOAuthTokens): void { this._tokens = tokens; } @@ -633,14 +665,14 @@ export class CrossAppAccessProvider implements OAuthClientProvider { * Saves the authorization server URL discovered during OAuth flow. * This is called by the auth() function after RFC 9728 discovery. */ - saveAuthorizationServerUrl?(authorizationServerUrl: string): void { + saveAuthorizationServerUrl(authorizationServerUrl: string): void { this._authorizationServerUrl = authorizationServerUrl; } /** * Returns the cached authorization server URL if available. */ - authorizationServerUrl?(): string | undefined { + authorizationServerUrl(): string | undefined { return this._authorizationServerUrl; } diff --git a/packages/client/src/client/client.examples.ts b/packages/client/src/client/client.examples.ts index 0789b1501a..dbc4ce00cf 100644 --- a/packages/client/src/client/client.examples.ts +++ b/packages/client/src/client/client.examples.ts @@ -7,8 +7,6 @@ * @module */ -import type { Prompt, Resource, Tool } from '@modelcontextprotocol/core'; - import { Client } from './client.js'; import { SSEClientTransport } from './sse.js'; import { StdioClientTransport } from './stdio.js'; @@ -107,9 +105,13 @@ async function Client_callTool_structuredOutput(client: Client) { arguments: { weightKg: 70, heightM: 1.75 } }); - // Machine-readable output for the client application - if (result.structuredContent) { - console.log(result.structuredContent); // e.g. { bmi: 22.86 } + // Machine-readable output for the client application. SEP-2106: structuredContent is + // `unknown` (any JSON value). Check for presence with `!== undefined` and narrow before use. + if (result.structuredContent !== undefined) { + const sc: unknown = result.structuredContent; // e.g. { bmi: 22.86 } + if (typeof sc === 'object' && sc !== null && 'bmi' in sc) { + console.log(sc.bmi); + } } //#endregion Client_callTool_structuredOutput } @@ -137,61 +139,43 @@ function Client_setRequestHandler_sampling(client: Client) { } /** - * Example: List tools with cursor-based pagination. + * Example: List tools (auto-aggregated across pages). */ async function Client_listTools_pagination(client: Client) { //#region Client_listTools_pagination - const allTools: Tool[] = []; - let cursor: string | undefined; - // Note: an empty-string cursor is valid and does not signal the end of results. - do { - const { tools, nextCursor } = await client.listTools({ cursor }); - allTools.push(...tools); - cursor = nextCursor; - } while (cursor !== undefined); + // No cursor → all pages aggregated for you. + const { tools } = await client.listTools(); console.log( 'Available tools:', - allTools.map(t => t.name) + tools.map(t => t.name) ); //#endregion Client_listTools_pagination } /** - * Example: List prompts with cursor-based pagination. + * Example: List prompts (auto-aggregated across pages). */ async function Client_listPrompts_pagination(client: Client) { //#region Client_listPrompts_pagination - const allPrompts: Prompt[] = []; - let cursor: string | undefined; - // Note: an empty-string cursor is valid and does not signal the end of results. - do { - const { prompts, nextCursor } = await client.listPrompts({ cursor }); - allPrompts.push(...prompts); - cursor = nextCursor; - } while (cursor !== undefined); + // No cursor → all pages aggregated for you. + const { prompts } = await client.listPrompts(); console.log( 'Available prompts:', - allPrompts.map(p => p.name) + prompts.map(p => p.name) ); //#endregion Client_listPrompts_pagination } /** - * Example: List resources with cursor-based pagination. + * Example: List resources (auto-aggregated across pages). */ async function Client_listResources_pagination(client: Client) { //#region Client_listResources_pagination - const allResources: Resource[] = []; - let cursor: string | undefined; - // Note: an empty-string cursor is valid and does not signal the end of results. - do { - const { resources, nextCursor } = await client.listResources({ cursor }); - allResources.push(...resources); - cursor = nextCursor; - } while (cursor !== undefined); + // No cursor → all pages aggregated for you. + const { resources } = await client.listResources(); console.log( 'Available resources:', - allResources.map(r => r.name) + resources.map(r => r.name) ); //#endregion Client_listResources_pagination } diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index bc3a91150b..a77a29b20c 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -2,64 +2,87 @@ import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/client/_shims' import type { BaseContext, CallToolRequest, + CallToolResult, ClientCapabilities, ClientContext, ClientNotification, ClientRequest, CompleteRequest, + CompleteResult, + DiscoverResult, + EmptyResult, GetPromptRequest, + GetPromptResult, Implementation, + InputRequiredOptions, + JSONRPCNotification, JSONRPCRequest, + JSONRPCResponse, JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, ListChangedHandlers, ListChangedOptions, ListPromptsRequest, + ListPromptsResult, ListResourcesRequest, + ListResourcesResult, ListResourceTemplatesRequest, + ListResourceTemplatesResult, ListToolsRequest, + ListToolsResult, LoggingLevel, MessageExtraInfo, + NonCompleteResultFlow, NotificationMethod, + ProtocolEra, ProtocolOptions, ReadResourceRequest, + ReadResourceResult, RequestMethod, RequestOptions, + ResolvedInputRequiredDriverConfig, Result, ServerCapabilities, + StandardSchemaV1, SubscribeRequest, + SubscriptionFilter, Tool, Transport, - UnsubscribeRequest + UnsubscribeRequest, + XMcpHeaderScanResult } from '@modelcontextprotocol/core'; import { - CallToolResultSchema, - CompleteResultSchema, - CreateMessageRequestSchema, - CreateMessageResultSchema, - CreateMessageResultWithToolsSchema, - ElicitRequestSchema, - ElicitResultSchema, - EmptyResultSchema, - GetPromptResultSchema, - InitializeResultSchema, - LATEST_PROTOCOL_VERSION, + buildMcpParamHeaders, + codecForVersion, + DEFAULT_REQUEST_TIMEOUT_MSEC, + DiscoverResultSchema, + HEADER_MISMATCH_ERROR_CODE, + isJSONRPCErrorResponse, + isJSONRPCRequest, + isModernProtocolVersion, + legacyProtocolVersions, ListChangedOptionsBaseSchema, - ListPromptsResultSchema, - ListResourcesResultSchema, - ListResourceTemplatesResultSchema, - ListToolsResultSchema, mergeCapabilities, + modernProtocolVersions, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, - ReadResourceResultSchema, + resolveInputRequiredDriverConfig, + runInputRequiredFlow, + scanXMcpHeaderDeclarations, SdkError, - SdkErrorCode + SdkErrorCode, + SUBSCRIPTION_ID_META_KEY, + SUPPORTED_MODERN_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; +import type { CacheMode, CacheScope, ResponseCacheStore } from './responseCache.js'; +import { ClientResponseCache, InMemoryResponseCacheStore, MAX_CACHE_TTL_MS } from './responseCache.js'; +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 +173,63 @@ export type ClientOptions = ProtocolOptions & { */ jsonSchemaValidator?: jsonSchemaValidator; + /** + * Opt-in protocol version negotiation (protocol revision 2026-07-28 and later). + * + * **The default is `'legacy'`**: absent (or `mode: 'legacy'`), `connect()` + * runs the plain 2025 sequence, byte-identical to today's behavior (no + * probe, no new headers). Opt into `'auto'` or pin to talk to a 2026-07-28 + * server. + * + * - `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?, maxRetries? }`; the probe + * inherits the client's standard request timeout unless overridden, and + * `maxRetries` (default `0`) governs timeout re-sends only — the + * spec-mandated `-32022` corrective continuation is never counted against it. + * + * Once a modern era is negotiated, the client automatically attaches the + * per-request `_meta` envelope (the reserved protocol-version / client-info / + * client-capabilities keys) to every outgoing request and notification; + * user-supplied `_meta` keys take precedence over the auto-attached ones. + */ + versionNegotiation?: VersionNegotiationOptions; + + /** + * Multi-round-trip auto-fulfilment (protocol revision 2026-07-28). + * + * On the 2026-07-28 era, servers obtain client input (elicitation, + * sampling, roots) by answering `tools/call`, `prompts/get`, or + * `resources/read` with an `input_required` result instead of sending a + * server→client request. By default the client fulfils those embedded + * requests automatically through the SAME handlers registered via + * {@linkcode Client.setRequestHandler | setRequestHandler} (e.g. + * `elicitation/create`), then retries the original call with the + * collected `inputResponses` and a byte-exact echo of the opaque + * `requestState`, on a fresh request id, up to `maxRounds` rounds. + * `client.callTool()` (and its siblings) keep returning their plain + * result type — the interactive rounds happen inside the call. + * + * Set `autoFulfill: false` for manual mode: an `input_required` response + * then surfaces as a typed error unless the individual call passes + * `allowInputRequired: true` (pair it with `withInputRequired()` on the + * explicit-schema path to type both outcomes). + * + * Has no effect on 2025-era connections, which have no `input_required` + * vocabulary. + */ + inputRequired?: InputRequiredOptions; + /** * Configure handlers for list changed notifications (tools, prompts, resources). * @@ -177,8 +257,189 @@ export type ClientOptions = ProtocolOptions & { * ``` */ listChanged?: ListChangedHandlers; + + /** + * Cap on the number of pages the auto-aggregating + * {@linkcode Client.listTools | listTools()} / + * {@linkcode Client.listPrompts | listPrompts()} / + * {@linkcode Client.listResources | listResources()} / + * {@linkcode Client.listResourceTemplates | listResourceTemplates()} walk + * fetches before throwing (a defence against a server whose `nextCursor` + * never converges). `0` disables the cap. Default: `64`. Applies only to + * the no-argument auto-aggregate path; an explicit-`cursor` per-page call + * is never capped. + */ + listMaxPages?: number; + + /** + * The response-cache store backing the client's derived views (the cached + * `tools/list` result that {@linkcode Client.callTool | callTool}'s output + * validation and SEP-2243 header mirroring read) and the SEP-2549 + * cache-hint serving on the cacheable verbs. Defaults to a fresh + * {@linkcode InMemoryResponseCacheStore} per client. + * + * Entries are automatically scoped by connected-server identity (derived + * from `serverInfo` after connect) AND, for `'private'`-scoped results, + * by `cachePartition` — encoded collision-free via `JSON.stringify`, so a + * server cannot craft a `serverInfo` that bleeds into another server's + * namespace or another principal's slot. One store may therefore back + * several clients (e.g. a host pool against the same server, or one + * persistent KV across servers); `list_changed` evictions are scoped to + * the connected server's partitions, so co-tenants are unaffected. Set + * `cachePartition` to your principal identifier (e.g. the auth subject) + * when sharing across principals. Note `serverInfo` is self-reported — a + * server that deliberately impersonates + * another's `name`/`version` shares its `'public'` slot; the + * per-principal isolation via `cachePartition` holds regardless. + */ + responseCacheStore?: ResponseCacheStore; + + /** + * Opaque per-principal identifier for response-cache writes whose + * server-reported `cacheScope` is `'private'` (the spec's "MUST NOT share + * across authorization contexts"). Within the connected server's + * namespace, `'public'`-scoped entries live at the shared + * `[serverIdentity, '']` partition and `'private'`-scoped entries at + * `[serverIdentity, cachePartition]`. Set this to a stable identity of + * the authorization context (e.g. the auth subject) when one + * `responseCacheStore` backs several principals; with the + * default `''` every entry — public or private — lives at the server's + * shared partition, which is the safe single-tenant posture. + */ + cachePartition?: string; + + /** + * TTL (ms) applied when a cacheable result arrives without a `ttlMs` + * field. Default `0` — a result without an explicit hint is never served + * from cache (every call refetches), but it is still **stored** so the + * `tools/list`-derived index that {@linkcode Client.callTool | callTool}'s + * SEP-2243 mirroring and output-schema validation read keeps working + * regardless. The spec defines absent-or-≤0 as "immediately stale". + */ + defaultCacheTtlMs?: number; +}; + +/** + * Options for {@linkcode Client.connect}. Extends {@linkcode RequestOptions} + * (the timeout/signal apply to the connect-time handshake or probe) with the + * zero-round-trip reconnect knob. + */ +export type ConnectOptions = RequestOptions & { + /** + * A previously-obtained {@linkcode DiscoverResult} (see + * {@linkcode Client.getDiscoverResult}). When supplied, `connect()` adopts + * it directly — zero round trips. 2026-07-28+ only: throws + * `SdkError(EraNegotiationFailed)` when there is no modern overlap. Only + * reuse across clients presenting the same authorization context. + */ + prior?: DiscoverResult; +}; + +/** + * {@linkcode RequestOptions} extended with the per-call cache disposition for + * the cacheable verbs (`listTools()` / `listPrompts()` / `listResources()` / + * `listResourceTemplates()` / `readResource()`). See {@linkcode CacheMode}. + */ +export type CacheableRequestOptions = RequestOptions & { + /** + * `'use'` (default) serves a still-fresh cached entry without a round + * trip; `'refresh'` always fetches and re-stores; `'bypass'` fetches + * without consulting or writing the cache. Applies to the no-`cursor` + * auto-aggregate path on the list verbs and to `readResource`; ignored + * elsewhere. + */ + cacheMode?: CacheMode; +}; + +/** + * Options for {@linkcode Client.callTool}. Extends {@linkcode RequestOptions} + * with an escape hatch for callers that already hold the tool definition + * (e.g. from a previous session or configuration) — pass it via + * `toolDefinition` so SEP-2243 `Mcp-Param-*` header mirroring can run without a + * prior `tools/list`. + */ +export type CallToolRequestOptions = RequestOptions & { + /** + * The tool definition to use for SEP-2243 `Mcp-Param-*` header mirroring on + * a 2026-07-28 connection over Streamable HTTP, AND for output-schema + * validation of the result. When set, the client uses this definition's + * `inputSchema` and `outputSchema` instead of (and without consulting) the + * cached `tools/list` result, so the two derived views agree. + */ + toolDefinition?: Tool; +}; + +/** + * `list_changed` notification → response-cache method(s) to evict. `resources` + * covers both list verbs (the spec's "relevant notification ⇒ immediately + * stale"). + */ +const LIST_CHANGED_EVICTIONS: Readonly> = { + 'notifications/tools/list_changed': ['tools/list'], + 'notifications/prompts/list_changed': ['prompts/list'], + 'notifications/resources/list_changed': ['resources/list', 'resources/templates/list'] }; +const DEFAULT_LIST_MAX_PAGES = 64; + +/** + * A handle to an open `subscriptions/listen` stream (protocol revision + * 2026-07-28). Change notifications delivered on the stream dispatch to the + * existing {@linkcode Client.setNotificationHandler} registrations. + */ +export interface McpSubscription { + /** + * The subset of the requested filter the server agreed to honor (from + * `notifications/subscriptions/acknowledged`). + */ + readonly honoredFilter: SubscriptionFilter; + /** + * Tears the subscription down. Idempotent. Aborts the listen request's + * stream (where the transport supports it) AND sends + * `notifications/cancelled` referencing the listen request id — both, + * always, so close works on any transport. + */ + close(): Promise; + /** + * Resolves exactly once when the subscription has terminated. Never + * rejects — this is an observation, not an operation. + * + * - `'local'` — you called {@linkcode close} (or aborted the + * `RequestOptions.signal` you passed to `listen()`). + * - `'graceful'` — the server ended the subscription deliberately by + * sending the empty `subscriptions/listen` response (e.g. on shutdown). + * - `'remote'` — the stream ended without a response, or the transport + * dropped — an unexpected disconnect. Re-listen if you still want + * events. + */ + readonly closed: Promise<'local' | 'graceful' | 'remote'>; +} + +/** @internal */ +interface ListenStateEntry { + /** + * The single funnel for the per-listen `opening → open → closed` state + * machine. Every transport-level feed source — the `_onnotification` / + * `_onresponse` / `_onclose` overrides, `onRequestStreamEnd`, send + * failure, ack timeout, caller-signal abort, `_resetConnectionState` — + * routes through it. + */ + settle: (outcome: { ack: SubscriptionFilter } | { cause: 'local' | 'graceful' | 'remote'; error?: Error }) => void; +} + +/** + * Per-tool result of compiling an `outputSchema` (SEP-2106). Stored on the + * response-cache substrate's stamp-keyed `name → validator` index so it + * inherits that substrate's invalidation lifecycle (`list_changed` evicts, + * a refetched `tools/list` re-derives, `resetForReconnect` clears) — no + * parallel map to keep in sync. + * + * @internal + */ +type OutputSchemaCompileResult = + | { ok: true; validator: JsonSchemaValidator } + | { ok: false; validator?: undefined; compileError: unknown }; + /** * An MCP client on top of a pluggable transport. * @@ -216,14 +477,101 @@ 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; - private _cachedToolOutputValidators: Map> = new Map(); + /** + * The response-cache substrate. Owns the backing store, the per-method + * eviction-generation counter, the user-supplied/default flag, and the + * stamp-memoized derived `name → Tool` / `name → output-validator` + * indices — the cache-coordination state that used to live as separate + * private fields here. The internal aggregating walk writes one entry per + * list verb; `list_changed` evicts the matching method; + * `_resetConnectionState` resets the lot. {@linkcode callTool}'s + * output-schema validation reads the derived `outputValidator` index (the + * substrate's first production caller); the stacked SEP-2243 PR wires + * `Mcp-Param-*` mirroring through `toolDefinition` on top. + */ + private readonly _cache: ClientResponseCache; + private readonly _defaultCacheTtlMs: number; + private readonly _listMaxPages: number; private _listChangedDebounceTimers: Map> = new Map(); - private _pendingListChangedConfig?: ListChangedHandlers; + /** + * The constructor `listChanged` configuration. Durable across reconnects: + * read fresh on every connect (legacy or modern), never consumed. + */ + private readonly _listChangedConfig?: ListChangedHandlers; private _enforceStrictCapabilities: boolean; + private _versionNegotiation?: VersionNegotiationOptions; + private _supportedProtocolVersionsOption?: string[]; + private _inputRequiredDriverConfig: ResolvedInputRequiredDriverConfig; + /** + * Active subscriptions/listen state, keyed by subscription id (= the + * listen request's JSON-RPC id verbatim). The id is a STRING from a + * Client-owned counter (`'listen:' + N`) — JSON-RPC permits string ids, + * and Protocol's numeric `_requestMessageId` counter only ever issues + * numbers, so listen ids cannot collide with ordinary request ids. + */ + private _listenState = new Map(); + private _nextListenId = 0; + /** The auto-opened subscription backing ClientOptions.listChanged on a modern connection. */ + private _autoOpenedSubscription?: McpSubscription; + /** Backing store for {@linkcode getDiscoverResult}. Per-connection. */ + private _discoverResult?: DiscoverResult; + + /** + * Clears every per-connection field in one place. Called at the start of + * each fresh (non-resuming) connect and from `close()`, so a stale + * negotiated era / server identity / auto-opened subscription cannot + * survive a reconnect. + */ + private _resetConnectionState(): void { + this._negotiatedProtocolVersion = undefined; + this._serverCapabilities = undefined; + this._serverVersion = undefined; + this._instructions = undefined; + this._discoverResult = undefined; + this._autoOpenedSubscription = undefined; + // Settle every live per-listen state machine before clearing the map: + // a fresh connect (or close) on a connection whose prior transport + // never fired onclose would otherwise leave an in-flight listen() + // promise hanging forever. Each entry's settle() deletes only itself + // (Map self-delete during iteration is well-defined). + if (this._listenState.size > 0) { + const reason = new SdkError( + SdkErrorCode.ConnectionClosed, + 'subscriptions/listen: client reconnected or closed; subscription state from the previous connection was reset' + ); + for (const entry of this._listenState.values()) { + entry.settle({ cause: 'remote', error: reason }); + } + } + this._listenState.clear(); + // Debounce timers are connection-scoped: a callback armed on a + // connection that is now gone must not fire onto whatever connection + // (if any) replaces it. + for (const timer of this._listChangedDebounceTimers.values()) { + clearTimeout(timer); + } + this._listChangedDebounceTimers.clear(); + // A user-supplied store is NOT cleared on reconnect/close — that would + // defeat the only reason to supply one. The per-instance default IS + // cleared (it is connection-scoped); derived indices and the + // generation map are dropped regardless. The default impl is + // synchronous, so the call returns plain void here. + this._cache.resetForReconnect(); + } + + override async close(): Promise { + try { + await super.close(); + } finally { + // Per-connection state is cleared even when the transport's close + // rejects, so a stale negotiated era / live listen state cannot + // survive a failed close. + this._resetConnectionState(); + } + } /** * Initializes this client with the given name and version information. @@ -236,10 +584,26 @@ 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; + // Multi-round-trip auto-fulfilment driver (2026-07-28): on by default, + // configurable via ClientOptions.inputRequired. + this._inputRequiredDriverConfig = resolveInputRequiredDriverConfig(options?.inputRequired); + // Response-cache substrate. A fresh in-memory store is allocated when + // the caller does not supply one — never share a default across + // instances (see ClientOptions.responseCacheStore). + this._cache = new ClientResponseCache( + options?.responseCacheStore ?? new InMemoryResponseCacheStore(), + options?.responseCacheStore !== undefined, + error => this._reportStoreError(error), + options?.cachePartition ?? '' + ); + this._defaultCacheTtlMs = options?.defaultCacheTtlMs ?? 0; + this._listMaxPages = options?.listMaxPages ?? DEFAULT_LIST_MAX_PAGES; // Store list changed config for setup after connection (when we know server capabilities) if (options?.listChanged) { - this._pendingListChangedConfig = options.listChanged; + this._listChangedConfig = options.listChanged; } } @@ -247,6 +611,88 @@ 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 _shouldDropInbound(message: JSONRPCRequest | JSONRPCNotification): 'drop' | undefined { + if ( + this._negotiatedProtocolVersion !== undefined && + isModernProtocolVersion(this._negotiatedProtocolVersion) && + isJSONRPCRequest(message) + ) { + return 'drop'; + } + return undefined; + } + + /** + * Per-request `_meta` envelope auto-emission (protocol revision 2026-07-28): + * on a connection that negotiated a modern era — auto-negotiated or pinned — + * every outgoing request and notification automatically carries the reserved + * protocol-version / client-info / client-capabilities `_meta` keys (the + * same envelope the connect-time `server/discover` probe sends). + * User-supplied `_meta` keys take precedence over the auto-attached ones. + * + * Legacy-era connections return `undefined`: the envelope seam is a no-op + * and outbound traffic is byte-identical to a 2025 client (the legacy + * `'auto'` fallback included). + */ + protected override _outboundMetaEnvelope(): Readonly> | undefined { + const version = this._negotiatedProtocolVersion; + if (version === undefined) return undefined; + // The era branch lives in the codec: the 2025 era's `outboundEnvelope` + // is `undefined` (legacy bytes stay identical); the 2026 era builds + // the keyed object. + return this._wireCodec().outboundEnvelope({ + protocolVersion: version, + clientInfo: this._clientInfo, + clientCapabilities: this._capabilities + }); + } + + /** + * Wires the multi-round-trip auto-fulfilment engine (protocol revision + * 2026-07-28) into the response funnel: an `input_required` answer is + * fulfilled through the registered elicitation/sampling/roots handlers + * and the original request retried via `flow.retry`, up to + * `inputRequired.maxRounds` rounds. With auto-fulfilment disabled the + * response surfaces as a typed error steering to manual mode. + */ + protected override _resolveNonCompleteResult( + decoded: { kind: 'input_required'; inputRequests: Record; requestState?: string }, + flow: NonCompleteResultFlow + ): Promise { + if (!this._inputRequiredDriverConfig.autoFulfill) { + return Promise.reject( + new SdkError( + SdkErrorCode.UnsupportedResultType, + `Unsupported result type 'input_required' for ${flow.request.method}: ` + + `multi-round-trip auto-fulfilment is not enabled on this instance — ` + + `pass allowInputRequired: true to handle it manually, or enable inputRequired.autoFulfill`, + { resultType: 'input_required', method: flow.request.method } + ) + ); + } + return runInputRequiredFlow( + { + getRequestHandler: method => + this._getRequestHandler(method) as ((request: JSONRPCRequest, ctx: unknown) => Promise) | undefined, + buildContext: baseCtx => this.buildContext(baseCtx, undefined), + sessionId: this.transport?.sessionId + }, + this._inputRequiredDriverConfig, + decoded, + flow + ); + } + /** * 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. @@ -254,23 +700,29 @@ export class Client extends Protocol { * @internal */ private _setupListChangedHandlers(config: ListChangedHandlers): void { + // Every autoRefresh fetcher forces `cacheMode: 'refresh'`: the + // notification handler's `_cache.evict()` runs first, but a custom + // store whose `delete()` no-ops (or rejects) leaves the stale entry + // in place — a default-mode `list*()` would then cache-serve the very + // entry the notification declared stale. `'refresh'` bypasses the + // cache read so the fetcher always reaches the wire and overwrites. if (config.tools && this._serverCapabilities?.tools?.listChanged) { this._setupListChangedHandler('tools', 'notifications/tools/list_changed', config.tools, async () => { - const result = await this.listTools(); + const result = await this.listTools(undefined, { cacheMode: 'refresh' }); return result.tools; }); } if (config.prompts && this._serverCapabilities?.prompts?.listChanged) { this._setupListChangedHandler('prompts', 'notifications/prompts/list_changed', config.prompts, async () => { - const result = await this.listPrompts(); + const result = await this.listPrompts(undefined, { cacheMode: 'refresh' }); return result.prompts; }); } if (config.resources && this._serverCapabilities?.resources?.listChanged) { this._setupListChangedHandler('resources', 'notifications/resources/list_changed', config.resources, async () => { - const result = await this.listResources(); + const result = await this.listResources(undefined, { cacheMode: 'refresh' }); return result.resources; }); } @@ -289,6 +741,21 @@ export class Client extends Protocol { this._capabilities = mergeCapabilities(this._capabilities, capabilities); } + /** + * Configure protocol version negotiation before connecting (equivalent to + * passing `versionNegotiation` at construction time). Can only be called + * before connecting to a transport. Passing `undefined` clears a previously + * configured negotiation, restoring the default `'legacy'` posture. + * + * See {@linkcode ClientOptions | ClientOptions.versionNegotiation} for the mode semantics. + */ + public setVersionNegotiation(options: VersionNegotiationOptions | undefined): void { + if (this.transport) { + throw new Error('Cannot configure version negotiation after connecting to transport'); + } + this._versionNegotiation = options; + } + /** * Enforces client-side validation for `elicitation/create` and `sampling/createMessage` * regardless of how the handler was registered. @@ -299,15 +766,31 @@ export class Client extends Protocol { ): (request: JSONRPCRequest, ctx: ClientContext) => Promise { if (method === 'elicitation/create') { return async (request, ctx) => { - const validatedRequest = parseSchema(ElicitRequestSchema, request); - if (!validatedRequest.success) { - // Type guard: if success is false, error is guaranteed to exist - const errorMessage = - validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid elicitation request: ${errorMessage}`); + // Era-exact validation via the function-only WireCodec + // contract: resolved from the instance era at dispatch time. + // On the 2025 era the method is a wire request (registry + // validator); on the 2026 era it is in-band vocabulary + // reached only via the multi-round-trip driver, so the wire + // validator returns `not-in-era` and we fall through to the + // in-band one. 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 codec = codecForVersion(this._negotiatedProtocolVersion); + let validatedRequest = codec.validateRequest('elicitation/create', request); + if (!validatedRequest.ok && validatedRequest.reason === 'not-in-era') { + validatedRequest = codec.validateInputRequest('elicitation/create', request); + } + if (!validatedRequest.ok) { + throw new ProtocolError( + validatedRequest.reason === 'not-in-era' ? ProtocolErrorCode.InternalError : ProtocolErrorCode.InvalidParams, + validatedRequest.reason === 'not-in-era' + ? 'No wire schema for elicitation/create in the resolved era' + : `Invalid elicitation request: ${validatedRequest.message}` + ); } - const { params } = validatedRequest.data; + const { params } = validatedRequest.value; params.mode = params.mode ?? 'form'; const { supportsFormMode, supportsUrlMode } = getSupportedElicitationModes(this._capabilities.elicitation); @@ -321,15 +804,20 @@ export class Client extends Protocol { const result = await handler(request, ctx); - const validationResult = parseSchema(ElicitResultSchema, result); - if (!validationResult.success) { - // Type guard: if success is false, error is guaranteed to exist - const errorMessage = - validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid elicitation result: ${errorMessage}`); + let validationResult = codec.validateResult('elicitation/create', result); + if (!validationResult.ok && validationResult.reason === 'not-in-era') { + validationResult = codec.validateInputResponse('elicitation/create', result); + } + if (!validationResult.ok) { + throw new ProtocolError( + validationResult.reason === 'not-in-era' ? ProtocolErrorCode.InternalError : ProtocolErrorCode.InvalidParams, + validationResult.reason === 'not-in-era' + ? 'No wire schema for elicitation/create in the resolved era' + : `Invalid elicitation result: ${validationResult.message}` + ); } - const validatedResult = validationResult.data; + const validatedResult = validationResult.value; const requestedSchema = params.mode === 'form' ? (params.requestedSchema as JsonSchemaType) : undefined; if ( @@ -352,27 +840,51 @@ export class Client extends Protocol { if (method === 'sampling/createMessage') { return async (request, ctx) => { - const validatedRequest = parseSchema(CreateMessageRequestSchema, request); - if (!validatedRequest.success) { - const errorMessage = - validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling request: ${errorMessage}`); + // Era-exact validation via the instance era (see above): wire + // request validator on the 2025 era; on the 2026 era sampling + // is in-band vocabulary (it reaches the handler only as an + // embedded input request) so the wire validator returns + // `not-in-era` and we fall through to the in-band one. + const codec = codecForVersion(this._negotiatedProtocolVersion); + let validatedRequest = codec.validateRequest('sampling/createMessage', request); + if (!validatedRequest.ok && validatedRequest.reason === 'not-in-era') { + validatedRequest = codec.validateInputRequest('sampling/createMessage', request); + } + if (!validatedRequest.ok) { + throw new ProtocolError( + validatedRequest.reason === 'not-in-era' ? ProtocolErrorCode.InternalError : ProtocolErrorCode.InvalidParams, + validatedRequest.reason === 'not-in-era' + ? 'No wire schema for sampling/createMessage in the resolved era' + : `Invalid sampling request: ${validatedRequest.message}` + ); } - const { params } = validatedRequest.data; + const { params } = validatedRequest.value; const result = await handler(request, ctx); - const hasTools = params.tools || params.toolChoice; - const resultSchema = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema; - const validationResult = parseSchema(resultSchema, result); - if (!validationResult.success) { - const errorMessage = - validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling result: ${errorMessage}`); + // The result-side validator mirrors the request-side selection + // so both stay on the same era's vocabulary. On the 2025 era + // the schema depends on the REQUEST params (tools vs no tools) + // — `samplingResultVariant` owns that pair. On the 2026 era + // it returns `not-in-era` and we fall through to the embedded + // response validator (it covers plain and tool-bearing + // responses alike). + const hasTools = Boolean(params.tools || params.toolChoice); + let validatedResult = codec.samplingResultVariant(hasTools, result); + if (!validatedResult.ok && validatedResult.reason === 'not-in-era') { + validatedResult = codec.validateInputResponse('sampling/createMessage', result); + } + if (!validatedResult.ok) { + throw new ProtocolError( + validatedResult.reason === 'not-in-era' ? ProtocolErrorCode.InternalError : ProtocolErrorCode.InvalidParams, + validatedResult.reason === 'not-in-era' + ? 'No result schema for sampling/createMessage in the resolved era' + : `Invalid sampling result: ${validatedResult.message}` + ); } - return validationResult.data; + return validatedResult.value; }; } @@ -414,28 +926,71 @@ export class Client extends Protocol { * } * ``` */ - override async connect(transport: Transport, options?: RequestOptions): Promise { + override async connect(transport: Transport, options?: ConnectOptions): Promise { + if (options?.prior !== undefined) { + // Zero-round-trip reconnect from a previously-obtained + // DiscoverResult: bypasses versionNegotiation resolution entirely. + return this._connectFromPrior(transport, options.prior); + } + 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: per-connection state 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._resetConnectionState(); + 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 +998,13 @@ 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; + this._cache.setServerIdentity(this._deriveServerIdentity(transport)); // HTTP transports must set the protocol version in each header after initialization. if (transport.setProtocolVersion) { transport.setProtocolVersion(result.protocolVersion); @@ -461,10 +1016,18 @@ 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); - this._pendingListChangedConfig = undefined; + if (this._listChangedConfig) { + this._setupListChangedHandlers(this._listChangedConfig); } } catch (error) { // Disconnect if initialization fails. @@ -473,6 +1036,195 @@ 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._resetConnectionState(); + + 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._cache.setServerIdentity(this._deriveServerIdentity(transport)); + this._instructions = result.discover.instructions; + this._discoverResult = result.discover; + // 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. On a modern + // connection the configured handlers are fed by an auto-opened + // subscriptions/listen stream (the modern era never delivers change + // notifications unsolicited); on a legacy connection they fire on the + // 2025-era unsolicited notifications, no listen needed. + if (this._listChangedConfig) { + const config = this._listChangedConfig; + // Compute configured ∩ server-advertised ONCE and use that single + // value for BOTH handler registration and the auto-open filter, so + // a configured-but-not-advertised type is neither subscribed to + // nor handled (the two stay in lockstep). + const advertised = this._serverCapabilities; + const effective: ListChangedHandlers = { + ...(config.tools && advertised?.tools?.listChanged && { tools: config.tools }), + ...(config.prompts && advertised?.prompts?.listChanged && { prompts: config.prompts }), + ...(config.resources && advertised?.resources?.listChanged && { resources: config.resources }) + }; + // Handler registration validates the per-type options and can + // throw on misconfiguration; the modern connection IS established + // at this point and is fully usable without listChanged handlers, + // so a misconfiguration surfaces via onerror and connect resolves + // (matching the auto-open soft-fail posture). When registration + // fails the auto-open is SKIPPED — opening a listen stream for + // types whose handler never registered would consume a server + // slot to deliver notifications nothing handles. + let handlersRegistered = true; + try { + this._setupListChangedHandlers(effective); + } catch (error) { + handlersRegistered = false; + this.onerror?.(error instanceof Error ? error : new Error(String(error))); + } + const filter: SubscriptionFilter = handlersRegistered + ? { + ...(effective.tools && { toolsListChanged: true as const }), + ...(effective.prompts && { promptsListChanged: true as const }), + ...(effective.resources && { resourcesListChanged: true as const }) + } + : {}; + if (Object.keys(filter).length > 0) { + // A failed auto-open MUST NOT fail connect: the modern + // connection is fully usable without a listen stream (the + // server may not support it, or refuse on capacity). Surface + // via onerror; the consumer can call listen() later. + // + // listen() binds RequestOptions.signal to the SUBSCRIPTION + // lifetime, so connect()'s signal must NOT be forwarded + // verbatim — a connect-scoped `AbortSignal.timeout(30_000)` + // would silently tear the auto-opened stream down the moment + // it fires after connect has resolved. But connect()'s signal + // MUST still cancel the in-connect ack WAIT (otherwise an + // aborted connect blocks here for the full ack timeout). + // Derived one-shot: bound to connect()'s signal only for the + // duration of the listen() await; the listener is removed in + // `finally` so the auto-opened subscription outlives connect's + // signal. + const ackAbort = new AbortController(); + const onConnectAbort = (): void => ackAbort.abort(options?.signal?.reason); + // Handle the already-aborted case (aborted between the + // discover leg resolving and now): the listener never fires + // for a past event. + if (options?.signal?.aborted) onConnectAbort(); + options?.signal?.addEventListener('abort', onConnectAbort); + try { + this._autoOpenedSubscription = await this.listen(filter, { + timeout: options?.timeout, + signal: ackAbort.signal + }); + } catch (error) { + // Connect-signal abort during the ack wait propagates as a + // connect() rejection (caller asked to abort connect). The + // transport is already started, so close it before + // rethrowing — a connect() rejection MUST NOT leave a + // half-open connection. A server-side refusal stays a + // soft onerror (connect succeeds, no listen stream). + if (options?.signal?.aborted) { + await this.close().catch(() => {}); + throw error; + } + this.onerror?.(error instanceof Error ? error : new Error(String(error))); + } finally { + options?.signal?.removeEventListener('abort', onConnectAbort); + } + } + } + } + + /** + * Connect from a previously-obtained {@linkcode DiscoverResult}. Always + * zero-round-trip; throws `EraNegotiationFailed` when there is no + * 2026-07-28+ overlap (no legacy fallback). See {@linkcode ConnectOptions}. + */ + private async _connectFromPrior(transport: Transport, prior: DiscoverResult): Promise { + this._resetConnectionState(); + + const explicit = this._supportedProtocolVersionsOption; + const clientModern = + explicit && modernProtocolVersions(explicit).length > 0 ? modernProtocolVersions(explicit) : SUPPORTED_MODERN_PROTOCOL_VERSIONS; + const version = clientModern.find(v => prior.supportedVersions.includes(v)); + if (version === undefined) { + throw new SdkError( + SdkErrorCode.EraNegotiationFailed, + "connect({ prior }) requires a 2026-07-28+ mutual protocol version; the supplied DiscoverResult and this client's " + + "supportedProtocolVersions have no modern overlap. Use versionNegotiation: { mode: 'auto' } for legacy-era fallback." + ); + } + + await super.connect(transport); + + this._discoverResult = prior; + this._serverCapabilities = prior.capabilities; + this._serverVersion = prior.serverInfo; + this._cache.setServerIdentity(this._deriveServerIdentity(transport)); + this._instructions = prior.instructions; + this._negotiatedProtocolVersion = version; + transport.setProtocolVersion?.(version); + + // No auto-opened listen stream on this path (request-only workers). + if (this._listChangedConfig) { + try { + this._setupListChangedHandlers(this._listChangedConfig); + } catch (error) { + this.onerror?.(error instanceof Error ? error : new Error(String(error))); + } + } + } + /** * After initialization has completed, this will be populated with the server's reported capabilities. */ @@ -487,6 +1239,20 @@ export class Client extends Protocol { return this._serverVersion; } + /** + * The connected server's identity for response-cache partitioning. The + * `serverInfo` `name@version` pair when available (the spec requires it on + * both `initialize` and `server/discover`); falls back to the transport's + * `sessionId` otherwise. The value itself is server-controlled — the + * collision-safety of the storage partition comes from + * {@linkcode ClientResponseCache}'s JSON-array encoding around it, not + * from any character it does or does not contain. + */ + private _deriveServerIdentity(transport: Transport): string { + const v = this._serverVersion; + return v === undefined ? (transport.sessionId ?? '') : `${v.name}@${v.version}`; + } + /** * After initialization has completed, this will be populated with the protocol version negotiated * during the initialize handshake. When manually reconstructing a transport for reconnection, pass this @@ -496,6 +1262,19 @@ export class Client extends Protocol { return this._negotiatedProtocolVersion; } + /** + * After initialization has completed, this returns the protocol era of the + * connection: `'modern'` when the connection negotiated a 2026-07-28+ + * revision (via `server/discover`), `'legacy'` for the 2025-era + * `initialize` handshake, or `undefined` before the connection is + * established. + */ + getProtocolEra(): ProtocolEra | undefined { + const version = this._negotiatedProtocolVersion; + if (version === undefined) return undefined; + return isModernProtocolVersion(version) ? 'modern' : 'legacy'; + } + /** * After initialization has completed, this may be populated with information about the server's instructions. */ @@ -503,6 +1282,15 @@ export class Client extends Protocol { return this._instructions; } + /** + * The {@linkcode DiscoverResult} from the last `'auto'`/pinned probe, + * {@linkcode discover} call, or `connect({ prior })`. Persistable via + * `JSON.stringify`; feed to {@linkcode ConnectOptions} `prior`. + */ + getDiscoverResult(): DiscoverResult | undefined { + return this._discoverResult; + } + protected assertCapabilityForMethod(method: RequestMethod | string): void { switch (method as ClientRequest['method']) { // Deprecated as of protocol version 2026-07-28 (SEP-2577); remains @@ -561,6 +1349,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 +1436,23 @@ 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); + } + + /** + * Send `server/discover` (2026-07-28+) and record the result for + * {@linkcode getDiscoverResult}. + */ + async discover(options?: RequestOptions): Promise { + const result = await this._requestWithSchema({ method: 'server/discover' }, DiscoverResultSchema, options); + this._discoverResult = result; + return result; } /** 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,84 +1462,113 @@ 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); } /** - * Lists available prompts. Results may be paginated — loop on `nextCursor` to collect all pages. + * Lists available prompts. + * + * Called without a `cursor` (the common case), this walks every page and + * returns the complete aggregated list with `nextCursor: undefined`; the + * aggregate is also written to the {@linkcode ResponseCacheStore}. Pass an + * explicit `{ cursor }` to fetch a single page and walk pagination + * yourself — the per-page path returns the server's raw page (with + * `nextCursor` for the next call) and does not write the response cache. + * The auto-aggregate path is capped by + * {@linkcode ClientOptions | ClientOptions.listMaxPages} (default 64); the per-page path + * is not. * * Returns an empty list if the server does not advertise prompts capability * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). * * @example * ```ts source="./client.examples.ts#Client_listPrompts_pagination" - * const allPrompts: Prompt[] = []; - * let cursor: string | undefined; - * // Note: an empty-string cursor is valid and does not signal the end of results. - * do { - * const { prompts, nextCursor } = await client.listPrompts({ cursor }); - * allPrompts.push(...prompts); - * cursor = nextCursor; - * } while (cursor !== undefined); + * // No cursor → all pages aggregated for you. + * const { prompts } = await client.listPrompts(); * console.log( * 'Available prompts:', - * allPrompts.map(p => p.name) + * prompts.map(p => p.name) * ); * ``` */ - async listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions) { + async listPrompts(params?: ListPromptsRequest['params'], options?: CacheableRequestOptions): 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); + if (params?.cursor !== undefined) { + return this.request({ method: 'prompts/list', params }, options); + } + const hit = await this._serveFromCache('prompts/list', undefined, options); + if (hit !== undefined) return hit; + return this._listAllPages('prompts/list', params, options, (acc, page) => acc.prompts.push(...page.prompts)); } /** - * Lists available resources. Results may be paginated — loop on `nextCursor` to collect all pages. + * Lists available resources. + * + * Called without a `cursor` (the common case), this walks every page and + * returns the complete aggregated list with `nextCursor: undefined`; the + * aggregate is also written to the {@linkcode ResponseCacheStore}. Pass an + * explicit `{ cursor }` to fetch a single page and walk pagination + * yourself — the per-page path returns the server's raw page (with + * `nextCursor` for the next call) and does not write the response cache. + * The auto-aggregate path is capped by + * {@linkcode ClientOptions | ClientOptions.listMaxPages} (default 64); the per-page path + * is not. * * Returns an empty list if the server does not advertise resources capability * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). * * @example * ```ts source="./client.examples.ts#Client_listResources_pagination" - * const allResources: Resource[] = []; - * let cursor: string | undefined; - * // Note: an empty-string cursor is valid and does not signal the end of results. - * do { - * const { resources, nextCursor } = await client.listResources({ cursor }); - * allResources.push(...resources); - * cursor = nextCursor; - * } while (cursor !== undefined); + * // No cursor → all pages aggregated for you. + * const { resources } = await client.listResources(); * console.log( * 'Available resources:', - * allResources.map(r => r.name) + * resources.map(r => r.name) * ); * ``` */ - async listResources(params?: ListResourcesRequest['params'], options?: RequestOptions) { + async listResources(params?: ListResourcesRequest['params'], options?: CacheableRequestOptions): 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); + if (params?.cursor !== undefined) { + return this.request({ method: 'resources/list', params }, options); + } + const hit = await this._serveFromCache('resources/list', undefined, options); + if (hit !== undefined) return hit; + return this._listAllPages('resources/list', params, options, (acc, page) => + acc.resources.push(...page.resources) + ); } /** - * Lists available resource URI templates for dynamic resources. Results may be paginated — see {@linkcode listResources | listResources()} for the cursor pattern. + * Lists available resource URI templates for dynamic resources. + * + * Called without a `cursor`, this walks every page and returns the + * complete aggregated list with `nextCursor: undefined`; the aggregate is + * also written to the {@linkcode ResponseCacheStore}. Pass an explicit + * `{ cursor }` to fetch a single page — see + * {@linkcode listResources | listResources()} for the per-page contract. * * 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?: CacheableRequestOptions + ): Promise { if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) { // Respect capability negotiation: server does not support resources console.debug( @@ -743,22 +1576,595 @@ export class Client extends Protocol { ); return { resourceTemplates: [] }; } - return this._requestWithSchema({ method: 'resources/templates/list', params }, ListResourceTemplatesResultSchema, options); + if (params?.cursor !== undefined) { + return this.request({ method: 'resources/templates/list', params }, options); + } + const hit = await this._serveFromCache('resources/templates/list', undefined, options); + if (hit !== undefined) return hit; + return this._listAllPages('resources/templates/list', params, options, (acc, page) => + acc.resourceTemplates.push(...page.resourceTemplates) + ); + } + + /** + * Walk every page of a paginated list verb, aggregate, and write ONE + * entry to the response cache. Internal — backs the public `list*` + * methods' no-`cursor` auto-aggregate path. Page 1's result object is + * mutated in place (its items array is extended; `nextCursor` is + * cleared); page-1 metadata (`ttlMs`, `cacheScope`, `_meta`) is preserved. + * A `nextCursor` that repeats stops the walk (defence against a + * non-converging server, mcp.d's `drainList` guard); + * {@linkcode ClientOptions.listMaxPages} is a hard cap — hitting it + * throws, so a partial aggregate is never cached. The + * captured-generation guard skips the write when a `list_changed` landed + * mid-walk, so the eviction is never overwritten by a stale aggregate. + * `finalize` runs on the complete aggregate before the cache write — the + * SEP-2243 invalid-`x-mcp-header` exclusion hooks here so the cached + * `tools/list` entry is already filtered. + * + * The caller's `baseParams` (everything except `cursor`) is threaded into + * every page request — page 1 sends `{...baseParams}`, later pages + * `{...baseParams, cursor}` — so a typed, documented `_meta` (e.g. W3C + * trace context) supplied to the public `list*()` reaches every wire + * request the walk issues. + */ + private async _listAllPages( + method: RequestMethod, + baseParams: { readonly [key: string]: unknown } | undefined, + options: CacheableRequestOptions | undefined, + append: (acc: R, page: R) => void, + finalize?: (acc: R) => void + ): Promise { + // `'bypass'` is the no-touch path: the cache is neither read nor + // written, so the substrate is byte-untouched (the `tools/list` + // derived index keeps whatever it held). + const bypass = options?.cacheMode === 'bypass'; + // Capture the eviction generation BEFORE page 1: a `list_changed` that + // lands mid-walk bumps the counter, and the terminal `write` skips + // when it observes the bump (the result still returns to the caller — + // it just is not cached). + const generation = this._cache.captureGeneration(method); + const acc = (await this.request({ method, ...(baseParams && { params: { ...baseParams } }) }, options)) as R; + let cursor = acc.nextCursor; + const seen = new Set(); + let pages = 1; + while (cursor !== undefined && !seen.has(cursor)) { + if (this._listMaxPages !== 0 && pages >= this._listMaxPages) { + throw new SdkError( + SdkErrorCode.ListPaginationExceeded, + `${method}: exceeded listMaxPages (${this._listMaxPages}); server pagination did not terminate`, + { method, listMaxPages: this._listMaxPages } + ); + } + seen.add(cursor); + const page = (await this.request({ method, params: { ...baseParams, cursor } }, options)) as R; + append(acc, page); + cursor = page.nextCursor; + pages++; + } + acc.nextCursor = undefined; + finalize?.(acc); + if (bypass) return acc; + // The aggregate is ALWAYS written: even when the resolved TTL is ≤0 + // the entry is stored already-stale (mcp.d's `retainForSchema` + // posture) so the `tools/list`-derived index keeps working regardless, + // while the freshness gate in `_serveFromCache` never serves it. + // Page-1 carries the result-level `ttlMs`/`cacheScope` (`acc` IS the + // mutated page-1 object). + await this._cache.write(method, acc, generation, this._freshness(acc)); + return acc; + } + + /** + * Compute the {@linkcode ClientResponseCache.write} freshness payload from + * a cacheable result body. The single seam through which the client reads + * `ttlMs`/`cacheScope` (mcp.d's `cachedFetch` engine). The fields pass + * through the loose result schema, so they are read off the runtime body; + * a missing `ttlMs` falls back to + * {@linkcode ClientOptions | ClientOptions.defaultCacheTtlMs}; an explicit server-sent + * `ttlMs` (including `0` — the spec's "immediately stale") is honoured + * as-is. The default of `0` means `expiresAt === now()` ⇒ never served, + * only stored. A missing `cacheScope` is treated as `'private'` — the + * spec's `'public'` grant ("any client … MAY serve to any user") is too + * strong to infer by default, and matches this SDK's server-side stamp + * default. + */ + private _freshness(result: unknown, params?: string): { expiresAt: number; scope: CacheScope; params?: string } { + const body = result as { ttlMs?: unknown; cacheScope?: unknown }; + const ttlMs = typeof body.ttlMs === 'number' ? body.ttlMs : this._defaultCacheTtlMs; + const scope: CacheScope = body.cacheScope === 'public' ? 'public' : 'private'; + // Clamp at 24h (`MAX_CACHE_TTL_MS`) so a server cannot pin an entry + // indefinitely; floor at 0 so a negative `ttlMs` is treated as + // immediately stale (the spec's absent-or-≤0 rule). + return { expiresAt: this._cache.now() + Math.min(Math.max(0, ttlMs), MAX_CACHE_TTL_MS), scope, params }; + } + + /** + * The cache-serving front of every cacheable verb (mcp.d's `cachedFetch` + * read half): under `cacheMode: 'use'` (the default), a held entry whose + * `expiresAt` is in the future is returned as a `structuredClone` and the + * round trip is skipped. `'refresh'` and `'bypass'` always fetch (the + * caller decides whether to write). A custom store whose `get()` rejects + * is routed to `onerror` and treated as a miss — cache bookkeeping never + * blocks a request from reaching the wire. + */ + private async _serveFromCache( + method: string, + params: string | undefined, + options: CacheableRequestOptions | undefined + ): Promise { + if (options?.cacheMode === 'bypass' || options?.cacheMode === 'refresh') return undefined; + const entry = await this._cache.read(method, params).catch(error => void this._reportStoreError(error)); + if (entry?.expiresAt !== undefined && entry.expiresAt > this._cache.now()) { + // A pre-aborted caller signal must reject the same way it would on + // the wire path (`Protocol.request()` wraps an already-aborted + // signal as `SdkError(RequestTimeout, reason)`); without this guard + // a cache hit would resolve successfully and silently swallow the + // abort. + if (options?.signal?.aborted) { + const reason = options.signal.reason; + throw reason instanceof SdkError ? reason : new SdkError(SdkErrorCode.RequestTimeout, String(reason)); + } + // Clone on the way out so a caller mutating the returned aggregate + // (e.g. `result.tools.sort(...)`) cannot reach the cache or the + // stamp-memoized indices derived from it — the same invariant + // `_cache.write` upholds on the way in. + return structuredClone(entry.value) as R; + } + return undefined; + } + + /** Route a custom-store failure to `onerror` without aborting the surrounding dispatch. */ + private _reportStoreError(e: unknown): void { + this.onerror?.(e instanceof Error ? e : new Error(String(e))); } - /** Reads the contents of a resource by URI. */ - async readResource(params: ReadResourceRequest['params'], options?: RequestOptions) { - return this._requestWithSchema({ method: 'resources/read', params }, ReadResourceResultSchema, options); + /** + * Compile a single tool's `outputSchema`. Passed as the compile callback to + * {@linkcode ClientResponseCache.outputValidator} so the cache class stays + * free of any validator-provider dependency, and called directly for the + * `options.toolDefinition` path of {@linkcode callTool} (a one-off + * caller-supplied definition is compiled in isolation and never enters the + * cache, so it cannot poison the listed tool of the same name). + * + * Returns `undefined` when the tool has no `outputSchema`, or a + * discriminated `{ok}` result otherwise. SEP-2106: ANY throw from the + * validator engine — unsupported `$schema` dialect, invalid `pattern` + * regex, unresolvable `$ref`, or any other engine error — is captured as + * `{ok: false, compileError}` so one bad schema does not poison the rest + * of the listing; `callTool()` surfaces it as an `InvalidParams` error + * before the request. The `{ok}` discriminator (not + * `compileError !== undefined`) means a custom provider that does + * `throw undefined` is still treated as a captured failure. + */ + private _compileOutputValidator(tool: Tool): OutputSchemaCompileResult | undefined { + if (!tool.outputSchema) return undefined; + try { + return { ok: true, validator: this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType) }; + } catch (error) { + return { ok: false, compileError: error }; + } + } + + /** + * Resolve the SEP-2243 `x-mcp-header` declaration scan for a tool name. + * + * The caller-supplied `toolDefinition` escape hatch wins; otherwise the + * cached `tools/list` entry (via the cache's `toolDefinition`) is the + * source. Freshness is the response cache's lifecycle: `list_changed` + * evicts, otherwise the held schema is the best information available + * regardless of age, and a stale schema is recovered through the + * `HEADER_MISMATCH` → evict-refetch-retry path in {@linkcode callTool}. + * On a miss the call proceeds without `Mcp-Param-*` headers (the spec's + * "client SHOULD send without custom headers" guidance) and relies on the + * same recovery. + */ + private async _resolveXMcpHeaderScan(name: string, override: Tool | undefined): Promise { + const tool = override ?? (await this._cache.toolDefinition(name)); + return tool === undefined ? undefined : scanXMcpHeaderDeclarations(tool.inputSchema); + } + + /** + * Reads the contents of a resource by URI. + * + * Honours the result's `ttlMs`/`cacheScope` (SEP-2549): a still-fresh + * cached body for the same `uri` is returned without a round trip + * (`cacheMode: 'use'`, the default). The cache key is `{method, uri}` + * partitioned by the resolved scope — `'private'` (the default when the + * server omits the field) is stored under this client's + * {@linkcode ClientOptions | ClientOptions.cachePartition}, so a shared + * store cannot serve one principal's resource body to another. Unlike the + * list verbs, a result whose resolved TTL is ≤0 is **not** stored + * (`resources/read` has no derived index and the URI keyspace is + * unbounded). + */ + async readResource(params: ReadResourceRequest['params'], options?: CacheableRequestOptions): Promise { + const hit = await this._serveFromCache('resources/read', params.uri, options); + if (hit !== undefined) return hit; + // Capture the per-URI eviction generation BEFORE the request: a + // `notifications/resources/updated` for this URI arriving while the + // read is in flight bumps it (via `evictKey`), and the terminal + // `write` skips so the now-stale body is not re-cached. Same guard as + // the list verbs' `_listAllPages`, keyed by `{method, uri}`. + const generation = this._cache.captureGeneration('resources/read', params.uri); + const result = await this.request({ method: 'resources/read', params }, options); + if (options?.cacheMode !== 'bypass') { + const freshness = this._freshness(result, params.uri); + if (freshness.expiresAt > this._cache.now()) { + await this._cache.write('resources/read', result, generation, freshness); + } else if (options?.cacheMode === 'refresh') { + // ttl≤0 → drop any held positive-TTL entry so the next + // default-mode read fetches fresh — a `'refresh'` that + // returns ttl≤0 must not leave the previously-warm entry + // serving stale until its old expiry (mcp.d's `cachedFetch` + // write half evicts on the no-store branch). Only on the + // `'refresh'` path: a default-mode miss already proved + // nothing fresh is held (`_serveFromCache` returned + // `undefined`), so `evictKey`'s store deletes would be wasted + // round trips against an async store on a ttl≤0 working set. + await this._cache.evictKey('resources/read', params.uri); + } + } + return result; } /** 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); + } + + /** + * Opens a `subscriptions/listen` stream (protocol revision 2026-07-28). + * + * Resolves once the server's `notifications/subscriptions/acknowledged` + * arrives (the standard request timeout applies to this ack phase). Change + * notifications delivered on the stream are dispatched to the existing + * {@linkcode setNotificationHandler} registrations — the same handlers the + * 2025-era unsolicited notifications fire on a legacy connection — so + * `listen()` is era-transparent for consumers that already register those. + * + * `close()` tears the subscription down by aborting the listen request's + * `requestSignal` (closes the SSE stream where the transport honors it) + * AND sending `notifications/cancelled` referencing the listen request id + * — both, unconditionally, so any spec-compliant server on any transport + * sees the cancel. No automatic re-listen — call `listen()` again to + * re-establish. + * + * On a 2025-era connection this throws a typed + * {@linkcode SdkErrorCode.MethodNotSupportedByProtocolVersion} steering to + * `resources/subscribe` and `ClientOptions.listChanged` (the legacy + * unsolicited delivery model still applies there); no transparent shim. + */ + async listen(filter: SubscriptionFilter, options?: RequestOptions): Promise { + // Connectivity is checked first so a closed instance rejects with + // NotConnected (no setup or ack timer is started); after close(), + // `_resetConnectionState` has also cleared the negotiated era, so the + // era guard alone would surface a misleading + // MethodNotSupportedByProtocolVersion. + if (this.transport === undefined) { + throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); + } + const negotiated = this._negotiatedProtocolVersion; + if (negotiated === undefined || !isModernProtocolVersion(negotiated)) { + throw new SdkError( + SdkErrorCode.MethodNotSupportedByProtocolVersion, + `subscriptions/listen requires a 2026-07-28-era connection (negotiated: ${negotiated ?? 'none'}). ` + + 'On a 2025-era connection, change notifications are delivered unsolicited: use ClientOptions.listChanged ' + + 'and resources/subscribe instead.', + { method: 'subscriptions/listen', protocolVersion: negotiated } + ); + } + + // Honor RequestOptions.signal exactly as request() does: an + // already-aborted signal rejects synchronously before any setup, and + // the rejection is the same `SdkError(RequestTimeout, reason)` wrap + // request() / `_serveFromCache` apply (unless `reason` is already an + // SdkError — preserved verbatim). + if (options?.signal?.aborted) { + const reason = options.signal.reason; + throw reason instanceof SdkError ? reason : new SdkError(SdkErrorCode.RequestTimeout, String(reason)); + } + + const requestAbort = new AbortController(); + // The listen request's JSON-RPC id (= the spec's subscription id + // verbatim). A STRING from a Client-owned counter so it cannot + // collide with Protocol's numeric `_requestMessageId` counter — the + // `_onresponse`/`_onnotification` overrides demux by string-id alone. + const listenId = `listen:${this._nextListenId++}`; + + // Explicit `opening → open → closed` state machine. Every termination + // path — ack-arrives, ack-timeout, server-cancelled, user-close, + // stream-end, transport-close, send-failure — funnels through the + // single `settle` below, which clears the ack timer, transitions + // state, and resolves/rejects the opening promise exactly once. The + // cancelled-before-ack / close-before-ack hangs are impossible by + // construction. + let state: 'opening' | 'open' | 'closed' = 'opening'; + let ackTimer: ReturnType | undefined; + let onCallerAbort: (() => void) | undefined; + let resolveOpening!: (honored: SubscriptionFilter) => void; + let rejectOpening!: (error: Error) => void; + const opening = new Promise((resolve, reject) => { + resolveOpening = resolve; + rejectOpening = reject; + }); + // The McpSubscription.closed observation. Resolved exactly once by + // settle()'s `→ closed` transition; never rejects. When listen() + // itself rejects (pre-ack) there is no McpSubscription to observe it + // on — settle() resolves it anyway so nothing dangles. + let resolveClosed!: (cause: 'local' | 'graceful' | 'remote') => void; + const closed = new Promise<'local' | 'graceful' | 'remote'>(resolve => { + resolveClosed = resolve; + }); + + const settle = (outcome: { ack: SubscriptionFilter } | { cause: 'local' | 'graceful' | 'remote'; error?: Error }): void => { + if (state === 'closed') return; + const wasOpening = state === 'opening'; + if (ackTimer !== undefined) { + clearTimeout(ackTimer); + ackTimer = undefined; + } + if ('ack' in outcome) { + // The single `opening → open` transition; an ack after close + // hits the `closed` guard above and is a no-op. + state = 'open'; + resolveOpening(outcome.ack); + return; + } + state = 'closed'; + if (onCallerAbort !== undefined) { + options?.signal?.removeEventListener('abort', onCallerAbort); + } + this._listenState.delete(listenId); + // Abort the per-request signal so an HTTP SSE reader stops on a + // remote-initiated close too (server-cancel / stream-end / + // transport-drop). Idempotent; a no-op on transports that ignore + // requestSignal. wireTeardown() also aborts on the local paths — + // harmless redundancy. + requestAbort.abort(); + resolveClosed(outcome.cause); + if (wasOpening) { + rejectOpening( + outcome.error ?? + new SdkError(SdkErrorCode.ConnectionClosed, 'subscriptions/listen closed before the server acknowledged') + ); + } + }; + + // Wire-level teardown for a locally-initiated close (user close, ack + // timeout, caller-signal abort). Transport-agnostic: ALWAYS abort the + // request signal (closes the SSE stream where the transport honors + // `requestSignal` — HTTP does, stdio does not) AND send + // `notifications/cancelled` referencing the listen id (which the + // stdio listen router and any spec-compliant server honor). Sent via + // `notification()` so the modern auto-envelope is attached exactly as + // for every other outbound. Idempotent over HTTP — the cancelled + // notification is a no-op once the stream is gone; correct on every + // other transport. Not called when the server already terminated. + const wireTeardown = async (): Promise => { + requestAbort.abort(); + await this.notification({ method: 'notifications/cancelled', params: { requestId: listenId } }).catch(() => {}); + }; + + const close = async (): Promise => { + if (state === 'closed') return; + settle({ cause: 'local' }); + await wireTeardown(); + }; + + // The per-subscription state is registered BEFORE the request is sent + // so a synchronously-delivered ack (an in-process transport) cannot + // race the registration. + this._listenState.set(listenId, { settle }); + + const ackTimeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; + ackTimer = setTimeout(() => { + settle({ + cause: 'remote', + error: new SdkError(SdkErrorCode.RequestTimeout, 'subscriptions/listen ack timed out', { timeout: ackTimeout }) + }); + void wireTeardown().catch(() => {}); + }, ackTimeout); + + // RequestOptions.signal aborts the subscription at any point in its + // lifecycle (mirrors request()'s cancel path). While `opening`, settle + // rejects the pending listen() promise with the signal's reason; while + // `open`, it transitions to `closed` (`closed` resolves `'local'`) and + // tears the wire down. The listener is removed by `settle()` once the + // subscription has closed. + if (options?.signal) { + const callerSignal = options.signal; + onCallerAbort = () => { + if (state === 'closed') return; + const reason = callerSignal.reason; + settle({ cause: 'local', error: reason instanceof Error ? reason : new Error(String(reason ?? 'Aborted')) }); + void wireTeardown().catch(() => {}); + }; + callerSignal.addEventListener('abort', onCallerAbort, { once: true }); + } + + // Send the listen request directly on the transport. The `_meta` + // envelope is built via the same `_outboundMetaEnvelope()` seam every + // other outbound uses (so a future envelope key cannot silently + // diverge here). `onRequestStreamEnd` feeds the per-request stream's + // non-deliberate end into the state machine on transports that open + // one (Streamable HTTP); stdio/InMemory ignore it. + const jsonrpcRequest: JSONRPCRequest = { + jsonrpc: '2.0', + id: listenId, + method: 'subscriptions/listen', + params: { _meta: { ...this._outboundMetaEnvelope() }, notifications: filter } + }; + try { + await this.transport.send(jsonrpcRequest, { + requestSignal: requestAbort.signal, + onRequestStreamEnd: () => settle({ cause: 'remote', error: new Error('subscriptions/listen: stream ended') }) + }); + } catch (error) { + // Synchronous OR awaited send failure (including a per-request + // abort fired before response headers — `streamableHttp._send` + // rethrows with onerror suppressed). `settle()` is idempotent so + // a locally-aborted send hitting this path after `close()` is a + // no-op. + settle({ cause: 'remote', error: error instanceof Error ? error : new Error(String(error)) }); + } + + const honored = await opening; + return { honoredFilter: honored, close, closed }; + } + + /** + * The subscription auto-opened by `ClientOptions.listChanged` on a modern + * connection — the listen filter is the intersection of the configured + * sub-options and the server-advertised `listChanged` capabilities. + * `undefined` on a legacy connection, before connect, or when that + * intersection is empty (auto-open skipped). Exposed so the consumer can + * `close()` it. + */ + get autoOpenedSubscription(): McpSubscription | undefined { + return this._autoOpenedSubscription; + } + + /** + * Transport-level demux for `subscriptions/listen` notifications, before + * any decoding/era-gating/handler dispatch. Consumes the leading + * `notifications/subscriptions/acknowledged` referencing a live + * subscription id (resolves the ack waiter) and an inbound + * `notifications/cancelled` referencing a live string-typed subscription + * id (server-side teardown on stdio). Change notifications carrying a + * subscription id pass through to the existing registered handlers via + * `super`. An unmatched ack/cancelled is NOT consumed: it reaches + * `setNotificationHandler` / `fallbackNotificationHandler` instead of + * being silently swallowed. + */ + protected override _onnotification(raw: JSONRPCNotification, extra?: MessageExtraInfo): void { + // Response-cache invalidation: a `list_changed` notification means the + // matching cached list result is stale. Evict (do NOT refetch) before + // dispatch so a handler that reaches the cache observes the cleared + // entry. Runs regardless of whether the user + // configured `listChanged` — derived views (the tool index, output + // validators) must drop the stale entry either way. `raw.method` is + // server-controlled; guard with `Object.hasOwn` so an inherited + // `Object.prototype` member name (`constructor`, `toString`, …) does + // not reach the iteration as a non-iterable function. + const evicted = Object.hasOwn(LIST_CHANGED_EVICTIONS, raw.method) ? LIST_CHANGED_EVICTIONS[raw.method] : undefined; + if (raw.method === 'notifications/resources/updated') { + // Per-URI eviction (mcp.d's `invalidateLogical`): the now-cached + // `resources/read` body for this URI is stale on receipt; drop it + // from BOTH partitions so the next `readResource` for the same URI + // refetches even within TTL (the documented subscribe → updated → + // re-read flow). Fire-and-forget like the `list_changed` path — + // dispatch must not block on an async store. + const uri = (raw.params as { uri?: unknown } | undefined)?.uri; + if (typeof uri === 'string') void this._cache.evictKey('resources/read', uri); + } else if (evicted !== undefined) { + for (const method of evicted) { + // `evict()` bumps the generation FIRST and unconditionally + // (the `ClientResponseCache.write` race guard relies on the + // bump, not on the store's deletes completing), then drops only THIS + // server's two partition singletons — co-tenants on a shared + // store keep their entries. Store failures are reported via + // `onerror` inside `evict()` and the call resolves, so + // dispatch (and the user's `listChanged` handler) runs + // regardless. Fire-and-forget — dispatch must not block on + // an async store. + void this._cache.evict(method); + } + } + if (raw.method === 'notifications/subscriptions/acknowledged') { + const params = raw.params as { _meta?: Record; notifications?: unknown } | undefined; + const subscriptionId = params?._meta?.[SUBSCRIPTION_ID_META_KEY]; + const entry = typeof subscriptionId === 'string' ? this._listenState.get(subscriptionId) : undefined; + if (entry !== undefined) { + // Route through the era codec (the ack is 2026-only + // vocabulary): a malformed honored filter still settles the + // listen — `{}` means "nothing honored". + const honored = this._wireCodec().validateNotification('notifications/subscriptions/acknowledged', raw); + entry.settle({ ack: honored.ok ? honored.value.params.notifications : {} }); + return; + } + } + if (raw.method === 'notifications/cancelled') { + const cancelledId = (raw.params as { requestId?: unknown } | undefined)?.requestId; + const entry = typeof cancelledId === 'string' ? this._listenState.get(cancelledId) : undefined; + if (entry !== undefined) { + // Handles BOTH the pre-ack and post-ack server-side cancel: + // while opening, settle rejects the pending listen() promise; + // once open, settle transitions to closed and `closed` resolves + // 'remote' so the consumer can observe the server-initiated + // close. + entry.settle({ cause: 'remote', error: new Error('subscriptions/listen: server cancelled the subscription') }); + return; + } + } + super._onnotification(raw, extra); + } + + /** + * Transport-level demux for `subscriptions/listen` responses. A JSON-RPC + * ERROR for the listen id is the server's pre-ack capacity/params + * rejection; a JSON-RPC RESULT for the listen id is the spec's + * `SubscriptionsListenResult` — the server's GRACEFUL-close signal (sent + * on shutdown). A string-id response that matches a live `_listenState` + * entry is consumed here (Protocol's `_responseHandlers` map is keyed by + * NUMBER and never holds a listen id, so passing a string-id response + * through would surface as "unknown message ID" via `onerror`). + */ + protected override _onresponse(response: JSONRPCResponse): void { + const id = response.id; + const entry = typeof id === 'string' ? this._listenState.get(id) : undefined; + if (entry !== undefined) { + if (isJSONRPCErrorResponse(response)) { + entry.settle({ + cause: 'remote', + error: ProtocolError.fromError(response.error.code, response.error.message, response.error.data) + }); + } else { + // The empty `SubscriptionsListenResult` — the server ended + // the subscription deliberately. Handles both pre-ack and + // post-ack: while opening, settle rejects the pending listen() + // promise with a ConnectionClosed (a server that answers + // before the ack is shutting down before serving); once open, + // settle transitions to closed and `closed` resolves + // 'graceful'. Per Q8, the result body itself is not validated + // — receipt for the listen id IS the signal (foreign servers + // may omit `_meta`). + entry.settle({ + cause: 'graceful', + error: new SdkError( + SdkErrorCode.ConnectionClosed, + 'subscriptions/listen: server closed the subscription gracefully before acknowledging' + ) + }); + } + return; + } + super._onresponse(response); + } + + /** + * Settle every live per-listen state machine on a transport-initiated + * close (the server dropping the connection on stdio/InMemory) before + * Protocol's `_onclose` tears the transport down. The base + * `_responseHandlers` settlement does not reach `_listenState` (listen + * ids are never registered there), so without this override a remote + * close would leave an in-flight `listen()` / open `McpSubscription` + * hanging. + */ + protected override _onclose(): void { + if (this._listenState.size > 0) { + const reason = new SdkError(SdkErrorCode.ConnectionClosed, 'Connection closed'); + for (const entry of this._listenState.values()) { + entry.settle({ cause: 'remote', error: reason }); + } + this._listenState.clear(); + } + super._onclose(); } /** @@ -792,20 +2198,140 @@ export class Client extends Protocol { * arguments: { weightKg: 70, heightM: 1.75 } * }); * - * // Machine-readable output for the client application - * if (result.structuredContent) { - * console.log(result.structuredContent); // e.g. { bmi: 22.86 } + * // Machine-readable output for the client application. SEP-2106: structuredContent is + * // `unknown` (any JSON value). Check for presence with `!== undefined` and narrow before use. + * if (result.structuredContent !== undefined) { + * const sc: unknown = result.structuredContent; // e.g. { bmi: 22.86 } + * if (typeof sc === 'object' && sc !== null && 'bmi' in sc) { + * console.log(sc.bmi); + * } * } * ``` */ - async callTool(params: CallToolRequest['params'], options?: RequestOptions) { - const result = await this._requestWithSchema({ method: 'tools/call', params }, CallToolResultSchema, options); + async callTool(params: CallToolRequest['params'], options?: CallToolRequestOptions): Promise { + // SEP-2243 `Mcp-Param-*` mirroring (protocol revision 2026-07-28; the + // 5-step client algorithm, steps 3–5). Modern-era only — the legacy + // `callTool` path is byte-untouched. Transports that share a single + // channel (stdio, in-memory) ignore the per-request `headers` option, + // so the spec's stdio MAY-ignore exemption holds without an explicit + // branch. In a browser environment, dynamically named `Mcp-Param-*` + // headers cannot be statically allow-listed for credentialed CORS + // (`Access-Control-Allow-Headers` admits no wildcard with + // credentials), so mirroring is skipped. NOTE: a conforming SEP-2243 + // server (including this SDK's own `createMcpHandler`) rejects a + // `tools/call` whose body carries a non-null value for an + // `x-mcp-header`-declared parameter when the matching `Mcp-Param-*` + // header is absent — so a browser client of this SDK cannot + // successfully call such a tool with that argument present unless the + // server relaxes the missing-header check. This is a known limitation + // of running SEP-2243 from a browser; pass `null` for the designated + // argument or supply `options.toolDefinition` against a relaxed server. + const mirroringActive = this.getProtocolEra() === 'modern' && detectProbeEnvironment() !== 'browser'; + // Mirroring (and output-schema validation below) read the cached + // `tools/list` entry directly via `_cache.toolDefinition` / + // `_cache.outputValidator`. `callTool` never issues a `tools/list` + // itself — the cache is populated by the caller's own + // {@linkcode listTools | listTools()} (which now auto-aggregates) and + // by the `HEADER_MISMATCH` recovery path below. A cold cache means + // the call proceeds without `Mcp-Param-*` headers (the spec's + // "client SHOULD send without custom headers" guidance) and without + // output-schema validation (the v1.x opportunistic behaviour, kept so + // a legacy/stdio `callTool` still issues zero extra requests). + const buildSendOptions = async (): Promise => { + if (!mirroringActive) return options; + // A custom store's `get()` may reject (the documented store + // contract); route that to `onerror` and degrade to sending + // without `Mcp-Param-*` headers — same posture as a cold cache — + // rather than aborting the call before it reaches the wire. + let scan: XMcpHeaderScanResult | undefined; + try { + scan = await this._resolveXMcpHeaderScan(params.name, options?.toolDefinition); + } catch (error) { + this._reportStoreError(error); + } + if (!scan?.valid || scan.declarations.length === 0) return options; + const paramHeaders = buildMcpParamHeaders(scan.declarations, params.arguments); + return Object.keys(paramHeaders).length === 0 ? options : { ...options, headers: { ...options?.headers, ...paramHeaders } }; + }; + + // SEP-2106: resolve the output validator BEFORE the request so a tool whose outputSchema + // fails to compile (unsupported `$schema` dialect / invalid pattern / unresolvable `$ref` / + // any engine error) is surfaced here, per-tool, without a wasted network round-trip and + // server-side handler execution. When the caller supplied `toolDefinition`, that definition + // is the source for BOTH the `Mcp-Param-*` mirroring above AND output validation — the two + // derived views must agree — and is compiled in isolation (never written to the cache). The + // cache read is guarded: a custom store whose `get()` rejects routes to `onerror` and + // degrades to skipping validation (same outcome as a cold cache). + let compiled = + options?.toolDefinition === undefined + ? await this._cache + .outputValidator(params.name, tool => this._compileOutputValidator(tool)) + .catch(error => void this._reportStoreError(error)) + : this._compileOutputValidator(options.toolDefinition); + const assertCompiled = (): void => { + if (compiled === undefined || compiled.ok) return; + const err = compiled.compileError; + const message = (err instanceof Error ? err.message : String(err)).slice(0, 200); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool '${params.name}' has an invalid outputSchema: ${message}`); + }; + assertCompiled(); + + // 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. + let result: CallToolResult; + try { + result = await this.request({ method: 'tools/call', params }, await buildSendOptions()); + } catch (error) { + // SEP-2243 one-refresh-on-miss: a `HEADER_MISMATCH` rejection on a + // modern connection means the server enforced an `Mcp-Param-*` + // header we did not (or could not) send — the cached `tools/list` + // entry is stale (or cold). Evict it, repopulate via + // {@linkcode listTools | listTools()} (which auto-aggregates and + // writes the cache), and retry once with the now-known schema. + // Never on the legacy era; never when the caller supplied + // `toolDefinition` (their schema is authoritative). + const isHeaderMismatch = error instanceof ProtocolError && error.code === HEADER_MISMATCH_ERROR_CODE; + if (!mirroringActive || !isHeaderMismatch || options?.toolDefinition !== undefined) { + throw error; + } + // `cacheMode: 'refresh'` so the recovery refetch always reaches + // the wire — `evict()` reports a custom store's `delete()` + // failure via `onerror` and resolves, but a no-op/failing + // `delete()` leaves the stale entry in place; without `'refresh'` + // the `listTools()` below would cache-serve the very entry whose + // staleness triggered this recovery. The generation bump still + // happens regardless, so the refetch's write overwrites. + const refreshOptions = { signal: options?.signal, timeout: options?.timeout, cacheMode: 'refresh' as const }; + await this._cache.evict('tools/list'); + // The recovery refetch may itself fail (e.g. `listMaxPages`, a + // transient error that hits only the `tools/list` walk). Surface + // it via `onerror` so the real cause is observable, then proceed + // to the retry. NOTE: when the refetch fails the cache stays + // empty and the retry goes out without `Mcp-Param-*` headers, so + // a conforming server will likely answer a second + // `HEADER_MISMATCH` — the refetch failure is observable only + // through `onerror`. + await this.listTools(undefined, refreshOptions).catch(error_ => this._reportStoreError(error_)); + // Re-resolve the output validator against the freshly-fetched entry — the pre-flight + // `compiled` was resolved from the now-evicted cache and may be stale (different + // outputSchema) or absent (cold cache on the first attempt). The recovery path is only + // entered when `options.toolDefinition` is undefined, so the cache is the sole source. + // Re-run the same fail-fast compile-error check before issuing the retry. + compiled = await this._cache + .outputValidator(params.name, tool => this._compileOutputValidator(tool)) + .catch(error_ => void this._reportStoreError(error_)); + assertCompiled(); + result = await this.request({ method: 'tools/call', params }, await buildSendOptions()); + } - // Check if the tool has an outputSchema - const validator = this.getToolOutputValidator(params.name); + const validator = compiled !== undefined && compiled.ok ? compiled.validator : undefined; if (validator) { - // If tool has outputSchema, it MUST return structuredContent (unless it's an error) - if (!result.structuredContent && !result.isError) { + // If tool has outputSchema, it MUST return structuredContent (unless it's an error). + // SEP-2106: presence is `=== undefined`, not falsy — `null`/`0`/`false`/`""` are legal + // values and are validated below (so a falsy value against an object-typed schema still + // fails; this is not a guard weakening). + if (result.structuredContent === undefined && !result.isError) { throw new ProtocolError( ProtocolErrorCode.InvalidRequest, `Tool ${params.name} has an output schema but did not return structured content` @@ -813,7 +2339,7 @@ export class Client extends Protocol { } // Only validate structured content if present (not when there's an error) - if (result.structuredContent) { + if (result.structuredContent !== undefined && !result.isError) { try { // Validate the structured content against the schema const validationResult = validator(result.structuredContent); @@ -840,62 +2366,88 @@ export class Client extends Protocol { } /** - * Cache validators for tool output schemas. - * Called after {@linkcode listTools | listTools()} to pre-compile validators for better performance. - */ - private cacheToolMetadata(tools: Tool[]): void { - this._cachedToolOutputValidators.clear(); - - for (const tool of tools) { - // If the tool has an outputSchema, create and cache the validator - if (tool.outputSchema) { - const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType); - this._cachedToolOutputValidators.set(tool.name, toolValidator); - } - } - } - - /** - * Get cached validator for a tool - */ - private getToolOutputValidator(toolName: string): JsonSchemaValidator | undefined { - return this._cachedToolOutputValidators.get(toolName); - } - - /** - * Lists available tools. Results may be paginated — loop on `nextCursor` to collect all pages. + * Lists available tools. + * + * Called without a `cursor` (the common case), this walks every page and + * returns the complete aggregated list with `nextCursor: undefined`; the + * aggregate is also written to the {@linkcode ResponseCacheStore} (the + * source for {@linkcode callTool | callTool()}'s output-schema validation + * and SEP-2243 `Mcp-Param-*` header mirroring). Pass an explicit + * `{ cursor }` to fetch a single page and walk pagination yourself — the + * per-page path returns the server's raw page (with `nextCursor` for the + * next call) and does not write the response cache. The auto-aggregate + * path is capped by {@linkcode ClientOptions | ClientOptions.listMaxPages} (default 64); + * the per-page path is not. * * Returns an empty list if the server does not advertise tools capability * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). * * @example * ```ts source="./client.examples.ts#Client_listTools_pagination" - * const allTools: Tool[] = []; - * let cursor: string | undefined; - * // Note: an empty-string cursor is valid and does not signal the end of results. - * do { - * const { tools, nextCursor } = await client.listTools({ cursor }); - * allTools.push(...tools); - * cursor = nextCursor; - * } while (cursor !== undefined); + * // No cursor → all pages aggregated for you. + * const { tools } = await client.listTools(); * console.log( * 'Available tools:', - * allTools.map(t => t.name) + * tools.map(t => t.name) * ); * ``` */ - async listTools(params?: ListToolsRequest['params'], options?: RequestOptions) { + async listTools(params?: ListToolsRequest['params'], options?: CacheableRequestOptions): 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); - - // Cache the tools and their output schemas for future validation - this.cacheToolMetadata(result.tools); + if (params?.cursor !== undefined) { + // Per-page: single request, never written to the response cache. + // SEP-2243: the spec's MUST has no carve-out for paginated reads, + // so the per-page result is filtered (on a non-stdio modern + // connection) before returning — the suppressed tool is never + // observable. + const page = await this.request({ method: 'tools/list', params }, options); + this._excludeInvalidXMcpHeaderTools(page); + return page; + } + const hit = await this._serveFromCache('tools/list', undefined, options); + if (hit !== undefined) return hit; + // Auto-aggregate: SEP-2243 invalid-`x-mcp-header` exclusion runs on + // the complete aggregate via the `finalize` hook before the cache + // write, so the cached entry never holds an unmirrorable tool. + return this._listAllPages( + 'tools/list', + params, + options, + (acc, page) => acc.tools.push(...page.tools), + acc => this._excludeInvalidXMcpHeaderTools(acc) + ); + } - return result; + /** + * SEP-2243 (protocol revision 2026-07-28): a Streamable HTTP client MUST + * exclude tool definitions whose `x-mcp-header` declarations violate the + * constraints, and SHOULD log a warning naming the tool and the reason. + * Applied to the CACHED aggregated `tools/list` result (so the entry + * mirroring reads never holds an unmirrorable tool) AND to every public + * per-page {@linkcode listTools | listTools()} return (the spec's MUST + * has no carve-out for paginated reads). The gate is era-only on + * non-stdio transports — `detectProbeTransportKind` cannot distinguish a + * real HTTP transport from in-memory/custom transports (it only + * positively recognizes stdio), and over-excluding on a non-HTTP modern + * connection is harmless: those transports never carry per-request + * headers, so an excluded tool would have been uncallable on a Streamable + * HTTP arm of the same server. Mutates `result.tools` in place. + */ + private _excludeInvalidXMcpHeaderTools(result: ListToolsResult): void { + if (this.getProtocolEra() !== 'modern' || !this.transport || detectProbeTransportKind(this.transport) === 'stdio') return; + const filtered = result.tools.filter(tool => { + const scan = scanXMcpHeaderDeclarations(tool.inputSchema); + if (!scan.valid) { + console.warn(`[mcp-sdk] excluding tool '${tool.name}' from tools/list: invalid x-mcp-header declaration — ${scan.reason}`); + return false; + } + return true; + }); + if (filtered.length !== result.tools.length) result.tools = filtered; } /** diff --git a/packages/client/src/client/crossAppAccess.ts b/packages/client/src/client/crossAppAccess.ts index 9e0219dfe6..87dadb9234 100644 --- a/packages/client/src/client/crossAppAccess.ts +++ b/packages/client/src/client/crossAppAccess.ts @@ -12,7 +12,7 @@ import type { FetchLike } from '@modelcontextprotocol/core'; import { IdJagTokenExchangeResponseSchema, OAuthErrorResponseSchema, OAuthTokensSchema } from '@modelcontextprotocol/core'; import type { ClientAuthMethod } from './auth.js'; -import { applyClientAuthentication, discoverAuthorizationServerMetadata } from './auth.js'; +import { applyClientAuthentication, assertSecureTokenEndpoint, discoverAuthorizationServerMetadata } from './auth.js'; /** * Options for requesting a JWT Authorization Grant via RFC 8693 Token Exchange. @@ -124,6 +124,8 @@ export interface JwtAuthGrantResult { export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantOptions): Promise { const { tokenEndpoint, audience, resource, idToken, clientId, clientSecret, scope, fetchFn = fetch } = options; + const tokenUrl = assertSecureTokenEndpoint(tokenEndpoint); + // Prepare token exchange request per RFC 8693 const params = new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', @@ -145,7 +147,7 @@ export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantO params.set('scope', scope); } - const response = await fetchFn(String(tokenEndpoint), { + const response = await fetchFn(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' @@ -260,6 +262,8 @@ export async function exchangeJwtAuthGrant(options: { }): Promise<{ access_token: string; token_type: string; expires_in?: number; scope?: string }> { const { tokenEndpoint, jwtAuthGrant, clientId, clientSecret, authMethod = 'client_secret_basic', fetchFn = fetch } = options; + const tokenUrl = assertSecureTokenEndpoint(tokenEndpoint); + // Prepare JWT bearer grant request per RFC 7523 const params = new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', @@ -272,7 +276,7 @@ export async function exchangeJwtAuthGrant(options: { applyClientAuthentication(authMethod, { client_id: clientId, client_secret: clientSecret }, headers, params); - const response = await fetchFn(String(tokenEndpoint), { + const response = await fetchFn(tokenUrl, { method: 'POST', headers, body: params.toString() diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts new file mode 100644 index 0000000000..0eef6cfa63 --- /dev/null +++ b/packages/client/src/client/probeClassifier.ts @@ -0,0 +1,262 @@ +/** + * 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 `-32022` 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 { + codecForVersion, + MODERN_WIRE_REVISION, + 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 `-32022` 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 } + /** + * `-32022` 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 `-32022` UnsupportedProtocolVersion protocol error code (negotiation-phase recognition). */ +const UNSUPPORTED_PROTOCOL_VERSION = -32_022; +/** + * Deliberately not probe-recognized in either direction: deployed servers + * overload `-32001` (the SDK-conventional `Session not found` body on a 2025 + * stateful server), and the spec-assigned `-32020` (`HeaderMismatch`) / + * `-32021` (`MissingRequiredClientCapability`) are not era evidence — all + * fall into the conservative legacy default. + */ +const NOT_PROBE_RECOGNIZED = new Set([-32_001, -32_020, -32_021]); + +/** + * 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 { + // The 2026 wire schema carries the spec receiver-side leniency for + // `resultType` ('complete'), `ttlMs` (0) and `cacheScope` ('private'), so + // routing through the codec is behavior-neutral with the prior public-schema + // parse for absent and malformed cache hints (`.catch()` per spec receiver + // leniency): a server that omits or malforms them still classifies `modern`. + const parsed = codecForVersion(MODERN_WIRE_REVISION).validateResult('server/discover', result); + if (!parsed.ok) { + // Unrecognized result shape: not modern evidence — conservative legacy fallback. + return { kind: 'legacy' }; + } + const supportedVersions = parsed.value.supportedVersions; + const overlap = context.clientModernVersions.find(version => supportedVersions.includes(version)); + if (overlap !== undefined) { + return { kind: 'modern', version: overlap, discover: parsed.value }; + } + // 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) { + // -32022 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/responseCache.ts b/packages/client/src/client/responseCache.ts new file mode 100644 index 0000000000..0fbf451cc2 --- /dev/null +++ b/packages/client/src/client/responseCache.ts @@ -0,0 +1,640 @@ +import type { ListToolsResult, Tool } from '@modelcontextprotocol/core'; + +/** + * Client-side response cache for SEP-2549 (`CacheableResult`) freshness hints. + * + * The store is a dumb keyed-value carrier: every freshness, scope and + * invalidation decision lives in the {@linkcode ClientResponseCache} (the + * `Client`'s single cache-coordination collaborator). The `stamp` field is + * mcp.d's re-derivation key — a derived view (e.g. the `name → Tool` index) + * re-computes only when the backing entry's stamp changes. + * + * Reference design: mcp.d `client/cache.d` / `client/client.d` (`CacheStore`, + * `cachedTool`, `cachedFetch`, `invalidateLogical`). + */ + +/** A value or a promise of one. The store interface is async-ready; the in-memory default returns plain values. */ +export type MaybePromise = T | Promise; + +/** The freshness scope of a cached entry (SEP-2549 `cacheHints.scope`). */ +export type CacheScope = 'public' | 'private'; + +/** + * Per-call cache disposition for the cacheable verbs (`listTools()` / + * `listPrompts()` / `listResources()` / `listResourceTemplates()` / + * `readResource()`): + * + * - `'use'` (the default) — serve a still-fresh cached entry without a round + * trip; on miss/stale, fetch and write. + * - `'refresh'` — always fetch (ignore any held entry) and write the fresh + * result. + * - `'bypass'` — fetch without consulting OR writing the cache (the result is + * not stored). The `tools/list`-derived index (mirroring / output + * validation) is therefore unaffected by a `'bypass'` call. + */ +export type CacheMode = 'use' | 'refresh' | 'bypass'; + +/** + * A logical cache address. `params` is the canonical result-affecting params + * key (`''` for the four list ops, the `uri` for `resources/read`); omitted is + * equivalent to `''`. `partition` namespaces the entry by connected-server + * identity AND per-principal scope: the `Client` writes a JSON-encoded + * `[serverIdentity, principal]` pair (so a server-controlled `serverInfo` + * string cannot bleed into the principal slot regardless of what characters + * it contains). A `'public'`-scoped entry lives at `[serverIdentity, '']`; a + * `'private'`-scoped entry at `[serverIdentity, cachePartition]`. Omitted is + * equivalent to `''`. + */ +export interface CacheKey { + readonly method: string; + readonly params?: string; + readonly partition?: string; +} + +/** + * One cached response body. `value` is the verbatim decoded result; `stamp` is + * the store-generated monotonically increasing write counter — opaque to + * callers. Derived views (e.g. a `name → Tool` index) memoize against it and + * re-derive only when it changes. `expiresAt` (absolute ms epoch, `now + + * ttlMs`) and `scope` are the client-computed freshness metadata; the store + * MUST persist them and hand them back on `get` so the read path can decide + * freshness and gate the shared-partition fallback on `scope === 'public'`. + */ +export interface CacheEntry { + readonly value: unknown; + readonly stamp: number; + readonly expiresAt?: number; + readonly scope?: CacheScope; +} + +/** + * The pluggable response-cache store. The interface is intentionally narrow; + * the in-memory default is the only implementation the SDK ships. + * + * Every method is async-ready ({@linkcode MaybePromise}) so a Redis-style + * store can implement the same interface without a later breaking change; the + * in-memory default stays synchronous (plain values are valid under + * `MaybePromise`). The `Client` `await`s every call site. + * + * Entries are keyed by `{method, params, partition}` where `partition` is the + * `Client`-derived `[serverIdentity, principal]` JSON pair, so one store + * instance is safe to share across `Client` instances connected to different + * servers and/or principals: writes from distinct connections never collide, + * the shared-partition read fallback is gated on the stored + * `scope === 'public'`, and `list_changed` / `HEADER_MISMATCH` evictions are + * scoped to the connected server's two partitions — co-tenants on a shared + * store are unaffected. The `Client` constructor still allocates a fresh + * {@linkcode InMemoryResponseCacheStore} per instance by default; supply your + * own to share or persist. + */ +export interface ResponseCacheStore { + get(key: CacheKey): MaybePromise; + /** + * Writes `entry` under `key` and returns the store-generated stamp the + * resulting {@linkcode CacheEntry} carries. The store owns the stamp + * counter; callers do not supply one. The caller owns `expiresAt` and + * `scope` (the client-computed freshness metadata); the store MUST persist + * them and hand them back on `get`. + */ + set(key: CacheKey, entry: { value: unknown; expiresAt?: number; scope?: CacheScope }): MaybePromise; + /** + * Drop the single entry under `key` (no-op if absent). Called for both + * `notifications/resources/updated` (per-URI) and the `list_changed` + * notifications (the list singletons live at `{method, params: '', partition}`). + */ + delete(key: CacheKey): MaybePromise; + /** + * Drop every entry for `method` across every partition. The `Client` does + * NOT call this (its `list_changed` path issues two partition-scoped + * `delete()` calls so co-tenants on a shared store keep their entries); + * kept on the interface for callers that want a method-wide bulk-clear. + */ + evict(method: string): MaybePromise; + /** Drop every entry (connection reset). */ + clear(): MaybePromise; +} + +/** Options for {@linkcode InMemoryResponseCacheStore}. */ +export interface InMemoryResponseCacheStoreOptions { + /** + * Maximum number of held `resources/read` entries (the only + * unbounded-keyspace method). When inserting a new `resources/read` key + * would exceed this, the oldest such entry (by insertion order) is + * evicted first. The list-singleton methods (`tools/list`, + * `prompts/list`, `resources/list`, `resources/templates/list`, + * `server/discover`) are **exempt** — they hold at most one entry per + * partition and back the `tools/list`-derived index, so an unbounded URI + * working set never displaces them. The default of `512` bounds growth on + * a long-lived client against template-expanded URIs. `0` disables the + * bound. + */ + maxEntries?: number; +} + +/** + * Methods whose entries are exempt from the + * {@linkcode InMemoryResponseCacheStoreOptions.maxEntries} cap. Each holds at + * most one entry per partition (a small bounded set) and the + * `tools/list`-derived index depends on its entry surviving regardless of the + * `resources/read` working-set size. Only `resources/read` keys count toward + * the cap and are eligible for FIFO eviction. + */ +const CAP_EXEMPT_METHODS: ReadonlySet = new Set([ + 'tools/list', + 'prompts/list', + 'resources/list', + 'resources/templates/list', + 'server/discover' +]); + +/** + * In-memory default. Bounded by an insertion-ordered size cap (default `512`; + * see {@linkcode InMemoryResponseCacheStoreOptions.maxEntries}) on the + * `resources/read` keyspace so an unbounded stream of distinct URIs cannot + * grow it without limit; the list-singleton methods are exempt and never + * evicted by the cap. `Map` preserves insertion order, so the oldest live + * capped key is the first matching iteration entry. + */ +export class InMemoryResponseCacheStore implements ResponseCacheStore { + private readonly _entries = new Map(); + private readonly _maxEntries: number; + private _stamp = 0; + /** Count of held entries that are subject to the cap (i.e. not in {@linkcode CAP_EXEMPT_METHODS}). */ + private _cappedSize = 0; + + constructor(options?: InMemoryResponseCacheStoreOptions) { + this._maxEntries = options?.maxEntries ?? 512; + } + + /** Number of held entries (for diagnostics / bounding tests). */ + get size(): number { + return this._entries.size; + } + + get(key: CacheKey): CacheEntry | undefined { + return this._entries.get(keyOf(key)); + } + + set(key: CacheKey, entry: { value: unknown; expiresAt?: number; scope?: CacheScope }): number { + const k = keyOf(key); + const exempt = CAP_EXEMPT_METHODS.has(key.method); + const isNew = !this._entries.has(k); + // Evict the oldest CAPPED entry first when adding a NEW capped key + // would exceed the cap (re-set of an existing key never evicts; an + // exempt-method write never evicts). `Map` iteration order is + // insertion order, so the first non-exempt key is the oldest one. + if (!exempt && isNew && this._maxEntries > 0 && this._cappedSize >= this._maxEntries) { + for (const oldKey of this._entries.keys()) { + if (!CAP_EXEMPT_METHODS.has(oldKey.slice(0, oldKey.indexOf('\0')))) { + this._entries.delete(oldKey); + this._cappedSize--; + break; + } + } + } + const stamp = ++this._stamp; + this._entries.set(k, { ...entry, stamp }); + if (isNew && !exempt) this._cappedSize++; + return stamp; + } + + delete(key: CacheKey): void { + if (this._entries.delete(keyOf(key)) && !CAP_EXEMPT_METHODS.has(key.method)) this._cappedSize--; + } + + evict(method: string): void { + const prefix = `${method}\0`; + const exempt = CAP_EXEMPT_METHODS.has(method); + for (const k of this._entries.keys()) { + if (k.startsWith(prefix)) { + this._entries.delete(k); + if (!exempt) this._cappedSize--; + } + } + } + + clear(): void { + this._entries.clear(); + this._cappedSize = 0; + } +} + +/** + * Serialize a {@linkcode CacheKey} for the in-memory map. `method` is always + * an SDK-set MCP method string (never contains a NUL), so the `\0` prefix + * delimiter is safe and lets {@linkcode InMemoryResponseCacheStore.evict} do a + * cheap prefix scan. `partition` (already a JSON-encoded + * `[serverIdentity, principal]` pair) and `params` (a resource URI on the + * `resources/read` path — caller-controlled) are JSON-array-encoded together, + * which is collision-free regardless of any NUL or delimiter characters they + * carry. + */ +function keyOf(key: CacheKey): string { + return `${key.method}\0${JSON.stringify([key.partition ?? '', key.params ?? ''])}`; +} + +/** + * Serialize a `{method, params}` pair for the eviction-generation map. The + * list singletons key on `method` alone (their {@linkcode ClientResponseCache.evict} + * is whole-method); `resources/read` keys on `` `${method}\0${uri}` `` so + * {@linkcode ClientResponseCache.evictKey} bumps a per-URI counter. + */ +function genKey(method: string, params?: string): string { + return params === undefined ? method : `${method}\0${params}`; +} + +/** + * Upper bound on the server-supplied `ttlMs` honoured by + * {@linkcode ClientResponseCache} (24h). A server cannot pin an entry + * indefinitely. + */ +export const MAX_CACHE_TTL_MS = 86_400_000; + +/** + * The `Client`'s cache-coordination collaborator. + * + * Owns the per-connection cache state that used to live as five private + * fields on `Client` — the backing {@linkcode ResponseCacheStore}, the + * per-method eviction-generation counter, the user-supplied/default flag, and + * the stamp-memoized derived indices over the `tools/list` entry. `Client` + * holds exactly one instance and never reaches past it to the store. + * + * Not exported from the package index — internal to the client package. + * + * @internal + */ +export class ClientResponseCache { + /** + * Per-logical-key eviction-generation counter. {@linkcode evict} (whole + * method) and {@linkcode evictKey} (single `{method, params}`) bump it + * before touching the store; {@linkcode captureGeneration} reads it before + * the request; {@linkcode write} skips when it moved — so a `list_changed` + * arriving mid-walk, or a `resources/updated` arriving while a + * `readResource()` for the same URI is in flight, is not overwritten by + * the in-flight request's stale write. The map key is `method` for the + * list singletons and `` `${method}\0${params}` `` for per-URI keys. + * + * Growth is bounded by keys the CLIENT has issued a `captureGeneration` + * for: {@linkcode captureGeneration} records the key (so an interleaved + * {@linkcode evictKey} sees there is an in-flight write to suppress); + * {@linkcode evictKey} only bumps a key that is already recorded — a + * server streaming `notifications/resources/updated` for URIs the client + * has never read therefore cannot grow this map. + */ + private readonly _evictionGeneration = new Map(); + /** + * `name → Tool` index derived from the cached `tools/list` entry, memoized + * against the entry's `stamp` so it re-derives only when the backing entry + * changes (mcp.d's `cachedTool` pattern). + */ + private _toolIndex?: { stamp: number; byName: Map }; + /** + * `name → compiled output-schema validator` derived from the cached + * `tools/list` entry; same stamp-keyed memoization as `_toolIndex`. Typed + * `unknown` so this class stays free of any validator-provider dependency + * — the compile callback supplied to {@linkcode outputValidator} owns the + * concrete type. + */ + private _toolOutputValidatorIndex?: { stamp: number; byName: Map }; + /** + * The connected server's identity (`serverInfo.name@version`, or a + * transport-supplied surrogate). Set by the `Client` immediately after a + * successful connect; `''` is the pre-connect sentinel. Every storage + * partition is derived from this (see `_partitionFor`), so two + * clients sharing one store but connected to different servers never + * collide on `tools/list` and a server cannot read another server's + * `'public'` entries. + */ + private _serverIdentity = ''; + + constructor( + private readonly _store: ResponseCacheStore, + /** + * Whether `_store` was supplied by the caller. A user-supplied store is + * never `clear()`ed by {@linkcode resetForReconnect} (defeats the only + * reason to supply one). + */ + private readonly _isUserSupplied: boolean, + /** + * Sink for a custom store's `set()`/`evict()` failure. {@linkcode write} + * never lets a store rejection cost the caller a result it already + * fetched — the failure is reported here and the write resolves. The + * `Client` wires this to `onerror`. + */ + private readonly _reportError: (error: unknown) => void = () => {}, + /** + * The opaque per-principal identifier for this client (the + * `private`-scope storage slot within the connected server's + * namespace). `''` (the default) makes the `private` slot identical to + * the server's shared `public` slot — the safe single-tenant posture. + * See `_partitionFor`. + */ + private readonly _cachePartition: string = '', + /** + * Clock seam (testing). The freshness check (`entry.expiresAt > now()`) + * and the `expiresAt = now() + ttlMs` stamp both read it via + * {@linkcode now}. Default `Date.now`. + */ + private readonly _now: () => number = Date.now + ) {} + + /** The clock used for every freshness computation and check. */ + now(): number { + return this._now(); + } + + /** + * Record the connected server's identity. Called by `Client` immediately + * after a successful connect (`serverInfo.name@version`, or the + * transport's `sessionId` when `serverInfo` is unavailable). Every + * partition derived after this call is scoped to this identity; entries + * written under the pre-connect `''` sentinel are no longer reachable. + */ + setServerIdentity(identity: string): void { + this._serverIdentity = identity; + } + + /** + * Derive the storage partition for `scope`. The encoding is + * `JSON.stringify([serverIdentity, principal])` — JSON escaping makes it + * collision-free by construction: a malicious server cannot craft a + * `serverInfo.name`/`version` whose concatenated form bleeds into another + * server's namespace or another principal's slot, regardless of `@` / `|` + * / `"` / NUL in the server-controlled strings. `'public'` → + * `[serverIdentity, '']` (shared within this server); `'private'` → + * `[serverIdentity, cachePartition]`. When `cachePartition` is `''` the + * two coincide. + */ + private _partitionFor(scope: CacheScope): string { + return JSON.stringify([this._serverIdentity, scope === 'public' ? '' : this._cachePartition]); + } + + /** + * Two-probe lookup: this client's own (private) partition first, then the + * connected server's shared (public) partition. The shared probe is gated + * on `entry.scope === 'public'` — a co-tenant client that omits + * `cachePartition` writes its `'private'`-scoped entries at the public + * partition, and serving those to a correctly-partitioned client would + * leak private bodies (mcp.d's `cachedEntry` two-probe order; the scope + * gate is defence-in-depth on top of the partition split). When + * `cachePartition` is `''` the two partitions are identical and only one + * probe is issued. + */ + private async _probe(method: string, params?: string): Promise { + const key = { method, params: params ?? '' }; + const ownPartition = this._partitionFor('private'); + const own = await this._store.get({ ...key, partition: ownPartition }); + if (own !== undefined) return own; + const sharedPartition = this._partitionFor('public'); + if (sharedPartition === ownPartition) return undefined; + const shared = await this._store.get({ ...key, partition: sharedPartition }); + return shared?.scope === 'public' ? shared : undefined; + } + + /** + * Bump the per-method generation (so an in-flight {@linkcode write} for the + * same method becomes a no-op) and drop the connected server's two list + * singletons (own + shared partition; `params: ''`). The generation bump + * is unconditional and FIRST — the {@linkcode write} race guard relies on + * the bump, not on the store's deletes completing. + * + * Eviction is scoped to this client's `[serverIdentity, principal]` + * partitions (mirroring {@linkcode evictKey}) — the method-wide + * `store.evict()` is NOT called, so on a shared store one server's + * `list_changed` cannot wipe a co-tenant's entry. A custom store's + * `delete()` may throw or reject; each partition is guarded + * independently so a failure on one does not skip the other, the failure + * is reported via the constructor's sink, and the call resolves so + * dispatch proceeds. + */ + async evict(method: string): Promise { + this._evictionGeneration.set(method, (this._evictionGeneration.get(method) ?? 0) + 1); + const ownPartition = this._partitionFor('private'); + const sharedPartition = this._partitionFor('public'); + try { + await this._store.delete({ method, params: '', partition: ownPartition }); + } catch (error) { + this._reportError(error); + } + if (sharedPartition !== ownPartition) { + try { + await this._store.delete({ method, params: '', partition: sharedPartition }); + } catch (error) { + this._reportError(error); + } + } + } + + /** + * Drop the single logical entry `{method, params}` from BOTH the private + * and public partitions for this client's connected server (mcp.d's + * `invalidateLogical`). Used for `notifications/resources/updated`'s + * per-URI eviction. The per-key generation is bumped FIRST (so an + * in-flight {@linkcode write} for the same `{method, params}` becomes a + * no-op and cannot re-cache the now-stale body) but only when the key was + * already recorded by {@linkcode captureGeneration} — bounding the map to + * keys the client has actually read. A custom store's `delete()` may + * throw or reject; each partition's delete is guarded independently so a + * failure on one does not skip the other, and the call resolves so + * dispatch proceeds. + */ + async evictKey(method: string, params: string): Promise { + const gk = genKey(method, params); + // Only bump a key the client has actually captured: if no entry is + // present there is no in-flight write to suppress, and an + // unconditional bump would let a server streaming distinct-URI + // `resources/updated` notifications grow this map without bound. The + // store deletes still run regardless (a previously-written entry may + // be held even when the generation entry has since been cleared by + // `resetForReconnect`). + const current = this._evictionGeneration.get(gk); + if (current !== undefined) this._evictionGeneration.set(gk, current + 1); + const ownPartition = this._partitionFor('private'); + const sharedPartition = this._partitionFor('public'); + try { + await this._store.delete({ method, params, partition: ownPartition }); + } catch (error) { + this._reportError(error); + } + if (sharedPartition !== ownPartition) { + try { + await this._store.delete({ method, params, partition: sharedPartition }); + } catch (error) { + this._reportError(error); + } + } + } + + /** + * Snapshot the eviction generation for `{method, params}` before issuing + * the request (a list walk's page 1, or a `resources/read` for `params`). + * Records the key so an interleaved {@linkcode evictKey} for the same + * `{method, params}` knows there is an in-flight write to suppress and + * bumps; without the record, `evictKey`'s recorded-only bump would skip + * and the stale body would be cached. + */ + captureGeneration(method: string, params?: string): number { + const gk = genKey(method, params); + const current = this._evictionGeneration.get(gk) ?? 0; + this._evictionGeneration.set(gk, current); + return current; + } + + /** + * Write `value` under `{method}` unless the per-method generation moved + * since `capturedGen` was taken — a `list_changed` that landed mid-walk has + * already invalidated the result the caller is about to write, and + * overwriting the eviction with the stale aggregate would lose the + * invalidation. + * + * The stored value is a `structuredClone` of `value`, so a caller + * mutating the aggregate it was returned (e.g. `result.tools.sort(...)`) + * cannot reach the cache or the stamp-memoized indices derived from it. A + * custom store whose `set()` throws or rejects is routed to the + * `reportError` sink and the write resolves — cache bookkeeping never + * costs the caller a result it already fetched (consistent with the + * eviction path). + * + * `freshness` carries the client-computed `expiresAt` (absolute ms epoch, + * `now + ttlMs`) and the server-reported `cacheScope`. The storage + * `partition` is derived from the scope via `_partitionFor`: + * `'public'` → `[serverIdentity, '']` (shared within this server); + * `'private'` → `[serverIdentity, cachePartition]` (so a shared store + * never serves a private entry to another identity). Absent `freshness` + * preserves the substrate write (no `expiresAt`, private partition) — the + * `tools/list` retain-for-schema posture: never served by + * {@linkcode read}'s freshness gate, always readable by + * {@linkcode toolDefinition}. + * + * After storing under the derived partition, the same `{method, params}` + * is deleted from the OPPOSITE partition (mirroring {@linkcode evictKey}'s + * two-partition posture). A server that flips a result's `cacheScope` for + * the same key would otherwise leave the previous entry in the other slot + * — and since `_probe` checks own-partition first, a stale private entry + * would shadow the fresh public one (or a stale public entry would keep + * serving co-tenants). Both store calls are independently guarded so a + * custom store's failure on one does not skip the other. + */ + async write( + method: string, + value: unknown, + capturedGen: number, + freshness?: { expiresAt: number; scope: CacheScope; params?: string } + ): Promise { + if ((this._evictionGeneration.get(genKey(method, freshness?.params)) ?? 0) !== capturedGen) return; + const params = freshness?.params ?? ''; + const ownPartition = this._partitionFor('private'); + const sharedPartition = this._partitionFor('public'); + const partition = (freshness?.scope ?? 'private') === 'public' ? sharedPartition : ownPartition; + try { + await this._store.set( + { method, params, partition }, + { value: structuredClone(value), expiresAt: freshness?.expiresAt, scope: freshness?.scope } + ); + } catch (error) { + this._reportError(error); + } + if (sharedPartition !== ownPartition) { + try { + await this._store.delete({ + method, + params, + partition: partition === ownPartition ? sharedPartition : ownPartition + }); + } catch (error) { + this._reportError(error); + } + } + } + + /** + * Read the cached entry for `{method, params}` via the two-probe lookup + * (own-partition then this server's shared partition, gated on + * `scope === 'public'`). The caller owns the freshness check + * (`entry.expiresAt > now()`); a missing `expiresAt` is never fresh. + */ + async read(method: string, params?: string): Promise { + return this._probe(method, params); + } + + /** + * Connection reset. The per-instance default store IS cleared + * (connection-scoped); a user-supplied store is NOT — that would defeat + * the only reason to supply one. The generation map and every derived + * index are dropped regardless: they are connection-scoped even when the + * backing store survives, so the next read re-derives from whatever the + * store still holds. The server identity returns to the pre-connect + * sentinel. The default impl is synchronous, so the `MaybePromise` + * return is a plain void here and the caller need not await. + */ + resetForReconnect(): void { + if (!this._isUserSupplied) void this._store.clear(); + this._evictionGeneration.clear(); + this._toolIndex = undefined; + this._toolOutputValidatorIndex = undefined; + this._serverIdentity = ''; + } + + /** + * The descriptor for tool `name` taken from the cached `tools/list` entry. + * The `name → Tool` index is memoized against the entry's `stamp` and + * re-derived only when the backing entry changes (mcp.d's `cachedTool`). + * Returns `undefined` only when no `tools/list` response is held at all, + * or the held list does not contain `name`. + * + * Consumed by `callTool()`'s SEP-2243 `_resolveXMcpHeaderScan` (mirroring) + * and, via {@linkcode outputValidator}, its output-schema validation. + */ + async toolDefinition(name: string): Promise { + const entry = await this._probe('tools/list'); + if (entry === undefined) { + this._toolIndex = undefined; + return undefined; + } + if (this._toolIndex?.stamp !== entry.stamp) { + const byName = new Map(); + for (const tool of (entry.value as ListToolsResult).tools) byName.set(tool.name, tool); + this._toolIndex = { stamp: entry.stamp, byName }; + } + return this._toolIndex.byName.get(name); + } + + /** + * The compiled output-schema validator for tool `name`, derived from the + * cached `tools/list` entry — same source and same stamp-keyed + * memoization as {@linkcode toolDefinition}. The `name → validator` index + * re-derives only when the backing entry's stamp changes (a refetched + * `tools/list` recompiles; a `list_changed` eviction drops it). Returns + * `undefined` when no `tools/list` is held, the tool is absent, or it has + * no `outputSchema`. + * + * `compile` is the caller-supplied validator-compile callback (the + * `Client` passes its `_jsonSchemaValidator` wrapper) so this + * class carries no validator-provider dependency. One tool's uncompilable + * `outputSchema` (e.g. an invalid `pattern` regex or unresolvable `$ref`) + * must not poison every other tool's `callTool` — the callback isolates + * that compile error per tool by returning a per-tool error variant which + * the index stores alongside the good ones, and `callTool` surfaces it as + * a typed `InvalidParams` only for that name. Because the error is held on + * this stamp-keyed substrate (not a parallel map), it inherits the + * substrate's invalidation lifecycle: a `list_changed` eviction drops it, + * a refetched `tools/list` re-derives it, and `resetForReconnect` clears + * the lot. + */ + async outputValidator(name: string, compile: (tool: Tool) => V | undefined): Promise { + const entry = await this._probe('tools/list'); + if (entry === undefined) { + this._toolOutputValidatorIndex = undefined; + return undefined; + } + if (this._toolOutputValidatorIndex?.stamp !== entry.stamp) { + const byName = new Map(); + for (const tool of (entry.value as ListToolsResult).tools) { + const compiled = compile(tool); + if (compiled !== undefined) byName.set(tool.name, compiled); + } + this._toolOutputValidatorIndex = { stamp: entry.stamp, byName }; + } + return this._toolOutputValidatorIndex.byName.get(name) as V | undefined; + } +} diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index bf554aba29..af7a1276ba 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -11,7 +11,16 @@ import type { ErrorEvent, EventSourceInit } from 'eventsource'; import { EventSource } from 'eventsource'; import type { AuthProvider, OAuthClientProvider } from './auth.js'; -import { adaptOAuthProvider, auth, extractWWWAuthenticateParams, isOAuthClientProvider, UnauthorizedError } from './auth.js'; +import { + adaptOAuthProvider, + auth, + extractWWWAuthenticateParams, + isOAuthClientProvider, + resolveAuthorizationCallbackParams, + UnauthorizedError +} from './auth.js'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced in JSDoc {@linkcode} +import type { IssuerMismatchError } from './authErrors.js'; export class SseError extends Error { constructor( @@ -44,6 +53,15 @@ export type SSEClientTransportOptions = { */ authProvider?: AuthProvider | OAuthClientProvider; + /** + * Opt-out for the RFC 8414 §3.3 issuer-echo check during authorization-server + * metadata discovery. **Security-weakening** — see + * {@linkcode index.AuthOptions.skipIssuerMetadataValidation | AuthOptions.skipIssuerMetadataValidation}. + * Only honoured when {@linkcode SSEClientTransportOptions.authProvider | authProvider} + * is an `OAuthClientProvider`. + */ + skipIssuerMetadataValidation?: boolean; + /** * Customizes the initial SSE request to the server (the request that begins the stream). * @@ -81,6 +99,7 @@ export class SSEClientTransport implements Transport { private _requestInit?: RequestInit; private _authProvider?: AuthProvider; private _oauthProvider?: OAuthClientProvider; + private _skipIssuerMetadataValidation?: boolean; private _fetch?: FetchLike; private _fetchWithInit: FetchLike; private _protocolVersion?: string; @@ -95,9 +114,12 @@ export class SSEClientTransport implements Transport { this._scope = undefined; this._eventSourceInit = opts?.eventSourceInit; this._requestInit = opts?.requestInit; + this._skipIssuerMetadataValidation = opts?.skipIssuerMetadataValidation; if (isOAuthClientProvider(opts?.authProvider)) { this._oauthProvider = opts.authProvider; - this._authProvider = adaptOAuthProvider(opts.authProvider); + this._authProvider = adaptOAuthProvider(opts.authProvider, { + skipIssuerMetadataValidation: opts.skipIssuerMetadataValidation + }); } else { this._authProvider = opts?.authProvider; } @@ -228,18 +250,47 @@ export class SSEClientTransport implements Transport { /** * Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth. + * + * **Preferred:** pass the callback URL's `searchParams` directly. The SDK extracts `code` + * and `iss`, validates `iss` against the recorded issuer (RFC 9207) **before** reading any + * other parameter, and on mismatch throws an {@linkcode IssuerMismatchError} that carries + * none of the callback's `error`/`error_description`/`error_uri` text. The `(code, iss?)` + * positional form remains supported for back-compat. + * + * The SDK does **not** validate `state`; compare it to your stored value before calling + * `finishAuth`. + * + * @param callbackParams - The `URLSearchParams` from the authorization callback URL + * (e.g. `new URL(callbackUrl).searchParams`). `code` and `iss` are read from it. */ - async finishAuth(authorizationCode: string): Promise { + async finishAuth(callbackParams: URLSearchParams): Promise; + /** + * @param authorizationCode - The `code` query parameter from the authorization callback URL. + * @param iss - The form-urldecoded `iss` query parameter from the same callback URL, if + * present. Validated per RFC 9207 against the recorded issuer before the code is redeemed. + */ + async finishAuth(authorizationCode: string, iss?: string): Promise; + async finishAuth(codeOrParams: string | URLSearchParams, iss?: string): Promise { if (!this._oauthProvider) { throw new UnauthorizedError('finishAuth requires an OAuthClientProvider'); } + const { authorizationCode, iss: issParam } = await resolveAuthorizationCallbackParams( + codeOrParams, + iss, + this._oauthProvider, + this._url, + { fetchFn: this._fetchWithInit, resourceMetadataUrl: this._resourceMetadataUrl } + ); + const result = await auth(this._oauthProvider, { serverUrl: this._url, authorizationCode, + iss: issParam, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + skipIssuerMetadataValidation: this._skipIssuerMetadataValidation }); if (result !== 'AUTHORIZED') { throw new UnauthorizedError('Failed to authorize'); diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 3b8ddafe5a..b3b8da915d 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -3,12 +3,15 @@ import type { ReadableWritablePair } from 'node:stream/web'; import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; import { createFetchWithInit, + encodeMcpParamValue, isInitializedNotification, isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, + isModernProtocolVersion, JSONRPCMessageSchema, normalizeHeaders, + PROTOCOL_VERSION_META_KEY, SdkError, SdkErrorCode, SdkHttpError @@ -16,7 +19,22 @@ import { import { EventSourceParserStream } from 'eventsource-parser/stream'; import type { AuthProvider, OAuthClientProvider } from './auth.js'; -import { adaptOAuthProvider, auth, extractWWWAuthenticateParams, isOAuthClientProvider, UnauthorizedError } from './auth.js'; +import { + adaptOAuthProvider, + auth, + computeScopeUnion, + extractWWWAuthenticateParams, + isOAuthClientProvider, + isStrictScopeSuperset, + resolveAuthorizationCallbackParams, + UnauthorizedError +} from './auth.js'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced via {@linkcode} in finishAuth JSDoc +import type { IssuerMismatchError } from './authErrors.js'; +import { InsufficientScopeError } from './authErrors.js'; + +/** Default cap on step-up re-authorization retries within a single send/stream-open. */ +const DEFAULT_MAX_STEP_UP_RETRIES = 1; // Default reconnection options for StreamableHTTP connections const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { @@ -49,6 +67,24 @@ export interface StartSSEOptions { * so that the response can be associated with the new resumed request. */ replayMessageId?: string | number; + + /** + * The per-request abort signal supplied by the caller via + * `TransportSendOptions.requestSignal`. When this signal is aborted the + * originating POST and its SSE response stream are torn down + * intentionally — `_handleSseStream` treats it exactly like the + * transport-level abort: no `onerror`, no reconnect. + */ + requestSignal?: AbortSignal; + + /** + * The per-request stream-end callback supplied via + * `TransportSendOptions.onRequestStreamEnd`. Fired when the SSE response + * stream for the originating POST ends or errors for any non-deliberate + * reason (server closed, network dropped, reconnection exhausted) — never + * when `requestSignal` was aborted. + */ + onRequestStreamEnd?: () => void; } /** @@ -128,6 +164,15 @@ export type StreamableHTTPClientTransportOptions = { */ authProvider?: AuthProvider | OAuthClientProvider; + /** + * Opt-out for the RFC 8414 §3.3 issuer-echo check during authorization-server + * metadata discovery. **Security-weakening** — see + * {@linkcode index.AuthOptions.skipIssuerMetadataValidation | AuthOptions.skipIssuerMetadataValidation}. + * Only honoured when {@linkcode StreamableHTTPClientTransportOptions.authProvider | authProvider} + * is an `OAuthClientProvider`. + */ + skipIssuerMetadataValidation?: boolean; + /** * Customizes HTTP requests to the server. */ @@ -161,8 +206,97 @@ export type StreamableHTTPClientTransportOptions = { * handshake so the reconnected transport continues sending the required header. */ protocolVersion?: string; + + /** + * How the transport reacts to a `403 Forbidden` response carrying + * `WWW-Authenticate: Bearer error="insufficient_scope"`. + * + * - `'reauthorize'` (default): the transport runs the step-up authorization + * flow — computes the union of the previously-requested scope and the + * challenged scope, calls {@linkcode index.auth | auth()} (forcing a + * fresh authorization request when the union strictly exceeds the current + * token's granted scope, since refresh cannot widen scope per RFC 6749 + * §6), and retries the request once. Retries are bounded by + * {@linkcode StreamableHTTPClientTransportOptions.maxStepUpRetries | maxStepUpRetries}. + * If no {@linkcode index.OAuthClientProvider | OAuthClientProvider} is + * configured, step-up cannot run and the transport throws + * {@linkcode index.InsufficientScopeError | InsufficientScopeError} instead. + * - `'throw'`: the transport throws {@linkcode index.InsufficientScopeError | InsufficientScopeError} + * carrying the challenge parameters and does not re-authorize. Use this + * for `client_credentials` / m2m clients where re-authorization cannot + * widen scope, or for interactive clients that want to gate the consent + * prompt behind UX. + * + * @default 'reauthorize' + */ + onInsufficientScope?: 'reauthorize' | 'throw'; + + /** + * Maximum number of step-up re-authorization attempts the transport makes + * per send (and per GET stream open) before giving up. Only consulted when + * {@linkcode StreamableHTTPClientTransportOptions.onInsufficientScope | onInsufficientScope} + * is `'reauthorize'`. Cross-request tracking ("this resource+operation + * already failed N times across the session") is host responsibility. + * + * @default 1 + */ + maxStepUpRetries?: number; }; +/** + * Standard/auth header names the transport owns. The per-request + * `TransportSendOptions.headers` carrier MUST NOT be able to override these — + * they are derived from connection state (`authorization`, `mcp-session-id`) + * or from the message body itself (`mcp-protocol-version`, `mcp-method`, + * `mcp-name`), and a per-request override would let a caller produce a + * header/body disagreement the server's SEP-2243 cross-checks reject. + */ +const RESERVED_REQUEST_HEADER_NAMES: ReadonlySet = new Set([ + 'authorization', + 'content-type', + 'mcp-protocol-version', + 'mcp-method', + 'mcp-name', + 'mcp-session-id' +]); + +/** + * `AbortSignal.any` with a manual fallback. `AbortSignal.any` landed in + * Node 20.3; this package's `engines` floor is `>=20`, so 20.0–20.2 must be + * served by the fallback combinator (a controller that aborts on the first + * of `a` or `b`). The native path is preferred because it propagates the + * originating signal's `reason` and participates in GC the way the spec + * defines. + */ +function anySignal(a: AbortSignal, b: AbortSignal): AbortSignal { + if (typeof AbortSignal.any === 'function') { + return AbortSignal.any([a, b]); + } + const controller = new AbortController(); + if (a.aborted) return (controller.abort(a.reason), controller.signal); + if (b.aborted) return (controller.abort(b.reason), controller.signal); + // Standard polyfill shape: when EITHER input fires, remove the listener + // registered on the OTHER input too. `{once:true}` alone leaks the + // sibling listener — for `_send()`, `a` is the transport-lifetime signal, + // so every request-scoped `b` that aborts would otherwise leave one + // listener + closure pinned on `a` for the life of the transport. + const cleanup = (): void => { + a.removeEventListener('abort', onA); + b.removeEventListener('abort', onB); + }; + function onA(): void { + cleanup(); + controller.abort(a.reason); + } + function onB(): void { + cleanup(); + controller.abort(b.reason); + } + a.addEventListener('abort', onA, { once: true }); + b.addEventListener('abort', onB, { once: true }); + return controller.signal; +} + /** * Client transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification. * It will connect to a server using HTTP `POST` for sending messages and HTTP `GET` with Server-Sent Events @@ -176,12 +310,14 @@ export class StreamableHTTPClientTransport implements Transport { private _requestInit?: RequestInit; private _authProvider?: AuthProvider; private _oauthProvider?: OAuthClientProvider; + private _skipIssuerMetadataValidation?: boolean; private _fetch?: FetchLike; private _fetchWithInit: FetchLike; private _sessionId?: string; private _reconnectionOptions: StreamableHTTPReconnectionOptions; private _protocolVersion?: string; - private _lastUpscopingHeader?: string; // Track last upscoping header to prevent infinite upscoping. + private _onInsufficientScope: 'reauthorize' | 'throw'; + private _maxStepUpRetries: number; private _serverRetryMs?: number; // Server-provided retry delay from SSE retry field private readonly _reconnectionScheduler?: ReconnectionScheduler; private _cancelReconnection?: () => void; @@ -190,14 +326,25 @@ export class StreamableHTTPClientTransport implements Transport { onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage) => void; + /** + * Streamable HTTP opens one POST (and SSE response stream) per outbound + * request and honors `TransportSendOptions.requestSignal`. On a 2026-era + * connection the protocol layer aborts that per-request stream as the + * spec cancellation signal instead of POSTing `notifications/cancelled`. + */ + readonly hasPerRequestStream = true; + constructor(url: URL, opts?: StreamableHTTPClientTransportOptions) { this._url = url; this._resourceMetadataUrl = undefined; this._scope = undefined; this._requestInit = opts?.requestInit; + this._skipIssuerMetadataValidation = opts?.skipIssuerMetadataValidation; if (isOAuthClientProvider(opts?.authProvider)) { this._oauthProvider = opts.authProvider; - this._authProvider = adaptOAuthProvider(opts.authProvider); + this._authProvider = adaptOAuthProvider(opts.authProvider, { + skipIssuerMetadataValidation: opts.skipIssuerMetadataValidation + }); } else { this._authProvider = opts?.authProvider; } @@ -207,6 +354,68 @@ export class StreamableHTTPClientTransport implements Transport { this._protocolVersion = opts?.protocolVersion; this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; this._reconnectionScheduler = opts?.reconnectionScheduler; + this._onInsufficientScope = opts?.onInsufficientScope ?? 'reauthorize'; + this._maxStepUpRetries = Math.max(0, opts?.maxStepUpRetries ?? DEFAULT_MAX_STEP_UP_RETRIES); + } + + /** + * SEP-2350 step-up: compute the union scope, decide whether refresh must be + * bypassed, and run {@linkcode auth}. Returns the auth result so the caller + * can decide whether to retry. Shared by the POST `_send` path and the GET + * `_startOrAuthSse` path so both apply the same `'throw'` short-circuit, + * the same superset-gated refresh bypass, and the same retry cap. + */ + private async _stepUpAuthorize( + challenge: { scope?: string; resourceMetadataUrl?: URL; errorDescription?: string; statusText?: string; text?: string | null }, + stepUpRetries: number + ): Promise<'AUTHORIZED' | 'REDIRECT'> { + if (this._onInsufficientScope === 'throw') { + throw new InsufficientScopeError({ + requiredScope: challenge.scope, + resourceMetadataUrl: challenge.resourceMetadataUrl, + errorDescription: challenge.errorDescription + }); + } + if (!this._oauthProvider) { + // No OAuth provider to drive step-up; surface the typed error so the + // host can act on it. + throw new InsufficientScopeError({ + requiredScope: challenge.scope, + resourceMetadataUrl: challenge.resourceMetadataUrl, + errorDescription: challenge.errorDescription + }); + } + if (stepUpRetries >= this._maxStepUpRetries) { + throw new SdkHttpError( + SdkErrorCode.ClientHttpForbidden, + `Server returned 403 insufficient_scope after step-up re-authorization (retry limit ${this._maxStepUpRetries} reached)`, + { status: 403, statusText: challenge.statusText ?? 'Forbidden', text: challenge.text } + ); + } + + if (challenge.resourceMetadataUrl) { + this._resourceMetadataUrl = challenge.resourceMetadataUrl; + } + + // Spec step-up: union of previously-requested scope and challenged scope, + // so previously-granted permissions are not lost on re-authorization. + const tokens = await this._oauthProvider.tokens(); + const unionScope = computeScopeUnion(this._scope, tokens?.scope, challenge.scope); + this._scope = unionScope; + + // Superset-gated refresh bypass: refresh cannot widen scope (RFC 6749 §6), + // so when the union strictly exceeds what the current token was granted + // we must force a fresh authorization request. + const forceReauthorization = isStrictScopeSuperset(unionScope, tokens?.scope); + + return auth(this._oauthProvider, { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: unionScope, + forceReauthorization, + fetchFn: this._fetchWithInit, + skipIssuerMetadataValidation: this._skipIssuerMetadataValidation + }); } private async _commonHeaders(): Promise { @@ -231,8 +440,70 @@ export class StreamableHTTPClientTransport implements Transport { }); } - private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false): Promise { - const { resumptionToken } = options; + /** + * 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); + // SEP-2243 standard headers, step 2 of the 5-step client algorithm: + // Mcp-Name mirrors `params.name` (tools/call, prompts/get) or + // `params.uri` (resources/read). The value is run through the same + // `=?base64?…?=` sentinel encoding the `Mcp-Param-*` codec uses so a + // non-ASCII name/URI (or one with leading/trailing whitespace, + // control characters, or CR/LF) cannot make `Headers.set()` throw a + // TypeError or silently normalize to a value that differs from the + // body. The spec's value-encoding rules apply to `Mcp-Name`; the SDK + // server's `validateStandardRequestHeaders` decodes the sentinel via + // `decodeMcpParamValue` before the `Mcp-Name` ↔ body cross-check. + const params = message.params as { name?: unknown; uri?: unknown } | undefined; + const nameHeader = + message.method === 'resources/read' + ? typeof params?.uri === 'string' + ? params.uri + : undefined + : typeof params?.name === 'string' + ? params.name + : undefined; + if (nameHeader !== undefined) { + headers.set('mcp-name', encodeMcpParamValue(nameHeader)); + } + } + + /** + * `true` when the outbound message is a single request carrying a + * modern-era protocol-version envelope claim — the same predicate that + * gates body-derived `mcp-method`/`mcp-name` emission. Used to confine the + * 400-body-as-ProtocolError delivery to modern-era exchanges only. + */ + private _isModernEnvelopedRequest(message: JSONRPCMessage | JSONRPCMessage[]): boolean { + if (Array.isArray(message) || !isJSONRPCRequest(message)) return false; + const meta = (message.params as { _meta?: Record } | undefined)?._meta; + const v = meta?.[PROTOCOL_VERSION_META_KEY]; + return typeof v === 'string' && isModernProtocolVersion(v); + } + + private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false, stepUpRetries = 0): Promise { + const { resumptionToken, requestSignal } = options; + // Same guard as `_handleSseStream`: a resurrected listen stream (the + // POST-SSE → GET reconnect path threads `requestSignal` through + // `StartSSEOptions`) must honour the per-request abort exactly as the + // original POST did — both as a fetch signal and as a "do not surface + // onerror" gate. + const isIntentionalAbort = (): boolean => this._abortController?.signal.aborted === true || requestSignal?.aborted === true; try { // Try to open an initial SSE stream with GET to listen for server messages @@ -247,11 +518,16 @@ export class StreamableHTTPClientTransport implements Transport { headers.set('last-event-id', resumptionToken); } + const transportSignal = this._abortController?.signal; + const signal = + requestSignal !== undefined && transportSignal !== undefined + ? anySignal(transportSignal, requestSignal) + : (requestSignal ?? transportSignal); const response = await (this._fetch ?? fetch)(this._url, { ...this._requestInit, method: 'GET', headers, - signal: this._abortController?.signal + signal }); if (!response.ok) { @@ -259,7 +535,9 @@ export class StreamableHTTPClientTransport implements Transport { if (response.headers.has('www-authenticate')) { const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); this._resourceMetadataUrl = resourceMetadataUrl; - this._scope = scope; + // Preserve any union accumulated by `_stepUpAuthorize` so a 401 + // mid-chain does not narrow `_scope` back to the challenge value. + this._scope = computeScopeUnion(this._scope, scope); } if (this._authProvider.onUnauthorized && !isAuthRetry) { @@ -270,7 +548,7 @@ export class StreamableHTTPClientTransport implements Transport { }); await response.text?.().catch(() => {}); // Purposely _not_ awaited, so we don't call onerror twice - return this._startOrAuthSse(options, true); + return this._startOrAuthSse(options, true, stepUpRetries); } await response.text?.().catch(() => {}); if (isAuthRetry) { @@ -282,11 +560,35 @@ export class StreamableHTTPClientTransport implements Transport { throw new UnauthorizedError(); } + if (response.status === 403) { + const { resourceMetadataUrl, scope, error, errorDescription } = extractWWWAuthenticateParams(response); + if (error === 'insufficient_scope') { + const text = await response.text?.().catch(() => null); + const result = await this._stepUpAuthorize( + { scope, resourceMetadataUrl, errorDescription, statusText: response.statusText, text }, + stepUpRetries + ); + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError(); + } + return this._startOrAuthSse(options, isAuthRetry, stepUpRetries + 1); + } + } + await response.text?.().catch(() => {}); // 405 indicates that the server does not offer an SSE stream at GET endpoint // This is an expected case that should not trigger an error if (response.status === 405) { + // A 405 on the standalone-GET path is benign (the caller + // never had a per-request stream). On the POST→GET resume + // path it is a TERMINAL non-resumable outcome for a + // per-request stream the caller is observing — fire the + // stream-end callback so the caller can settle (otherwise + // a resumed listen subscription dead-ends silently). The + // standalone-GET callers never pass `onRequestStreamEnd`, + // so this is a no-op for them. + options.onRequestStreamEnd?.(); return; } @@ -298,7 +600,9 @@ export class StreamableHTTPClientTransport implements Transport { this._handleSseStream(response.body, options, true); } catch (error) { - this.onerror?.(error as Error); + if (!isIntentionalAbort()) { + this.onerror?.(error as Error); + } throw error; } } @@ -337,6 +641,8 @@ export class StreamableHTTPClientTransport implements Transport { // Check if we've exceeded maximum retry attempts if (attemptCount >= maxRetries) { this.onerror?.(new Error(`Maximum reconnection attempts (${maxRetries}) exceeded.`)); + // The per-request stream is now definitively gone. + options.onRequestStreamEnd?.(); return; } @@ -345,8 +651,12 @@ export class StreamableHTTPClientTransport implements Transport { const reconnect = (): void => { this._cancelReconnection = undefined; - if (this._abortController?.signal.aborted) return; + // Honour BOTH the transport-wide abort and the per-request abort + // (a listen subscription closed during the backoff delay): do not + // resurrect a stream the caller already tore down. + if (this._abortController?.signal.aborted || options.requestSignal?.aborted) return; this._startOrAuthSse(options).catch(error => { + if (this._abortController?.signal.aborted || options.requestSignal?.aborted) return; this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`)); try { this._scheduleReconnection(options, attemptCount + 1); @@ -367,9 +677,20 @@ export class StreamableHTTPClientTransport implements Transport { private _handleSseStream(stream: ReadableStream | null, options: StartSSEOptions, isReconnectable: boolean): void { if (!stream) { + // A null body on a per-request stream (or its GET resume) is the + // same terminal non-resumable outcome as a 405 — fire the + // stream-end callback so the caller can settle. No-op for + // standalone-GET callers (they never pass `onRequestStreamEnd`). + options.onRequestStreamEnd?.(); return; } - const { onresumptiontoken, replayMessageId } = options; + const { onresumptiontoken, replayMessageId, requestSignal, onRequestStreamEnd } = options; + // An intentional abort — transport-wide close OR a per-request abort + // (McpSubscription.close() aborting its `requestSignal`) — must read as + // a clean shutdown: no misleading "SSE stream disconnected" onerror, + // and no GET+Last-Event-ID reconnect that would resurrect a stream the + // caller just tore down. + const isIntentionalAbort = (): boolean => this._abortController?.signal.aborted === true || requestSignal?.aborted === true; let lastEventId: string | undefined; // Track whether we've received a priming event (event with ID) @@ -438,17 +759,29 @@ export class StreamableHTTPClientTransport implements Transport { // BUT don't reconnect if we already received a response - the request is complete const canResume = isReconnectable || hasPrimingEvent; const needsReconnect = canResume && !receivedResponse; - if (needsReconnect && this._abortController && !this._abortController.signal.aborted) { + if (needsReconnect && this._abortController && !isIntentionalAbort()) { this._scheduleReconnection( { resumptionToken: lastEventId, onresumptiontoken, - replayMessageId + replayMessageId, + requestSignal, + onRequestStreamEnd }, 0 ); + } else if (!isIntentionalAbort()) { + // The per-request stream ended without reconnecting (no + // priming event for a POST stream, or response already + // received). Not a deliberate abort — notify the caller. + onRequestStreamEnd?.(); } } catch (error) { + if (isIntentionalAbort()) { + // The reader threw because we aborted it. Not an error; do + // not surface onerror, do not reconnect. + return; + } // Handle stream errors - likely a network disconnect this.onerror?.(new Error(`SSE stream disconnected: ${error}`)); @@ -457,20 +790,27 @@ export class StreamableHTTPClientTransport implements Transport { // BUT don't reconnect if we already received a response - the request is complete const canResume = isReconnectable || hasPrimingEvent; const needsReconnect = canResume && !receivedResponse; - if (needsReconnect && this._abortController && !this._abortController.signal.aborted) { + if (needsReconnect && this._abortController && !isIntentionalAbort()) { // Use the exponential backoff reconnection strategy try { this._scheduleReconnection( { resumptionToken: lastEventId, onresumptiontoken, - replayMessageId + replayMessageId, + requestSignal, + onRequestStreamEnd }, 0 ); } catch (error) { this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`)); + onRequestStreamEnd?.(); } + } else { + // Non-deliberate stream error without reconnection: the + // per-request stream is gone — notify the caller. + onRequestStreamEnd?.(); } } }; @@ -489,18 +829,50 @@ export class StreamableHTTPClientTransport implements Transport { /** * Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth. + * + * **Preferred:** pass the callback URL's `searchParams` directly. The SDK extracts `code` + * and `iss`, validates `iss` against the recorded issuer (RFC 9207) **before** reading any + * other parameter, and on mismatch throws an {@linkcode IssuerMismatchError} that carries + * none of the callback's `error`/`error_description`/`error_uri` text — those are + * attacker-controlled in a mix-up attack and MUST NOT be displayed. The `(code, iss?)` + * positional form remains supported for back-compat. + * + * The SDK does **not** validate `state`; compare it to your stored value before calling + * `finishAuth`. + * + * @param callbackParams - The `URLSearchParams` from the authorization callback URL + * (e.g. `new URL(callbackUrl).searchParams`). `code` and `iss` are read from it. + */ + async finishAuth(callbackParams: URLSearchParams): Promise; + /** + * @param authorizationCode - The `code` query parameter from the authorization callback URL. + * @param iss - The form-urldecoded `iss` query parameter from the same callback URL, if + * present. Validated per RFC 9207 against the recorded issuer before the code is redeemed. + * When the authorization server advertises `authorization_response_iss_parameter_supported: true`, + * omitting this causes the exchange to be **rejected** with {@linkcode IssuerMismatchError}. */ - async finishAuth(authorizationCode: string): Promise { + async finishAuth(authorizationCode: string, iss?: string): Promise; + async finishAuth(codeOrParams: string | URLSearchParams, iss?: string): Promise { if (!this._oauthProvider) { throw new UnauthorizedError('finishAuth requires an OAuthClientProvider'); } + const { authorizationCode, iss: issParam } = await resolveAuthorizationCallbackParams( + codeOrParams, + iss, + this._oauthProvider, + this._url, + { fetchFn: this._fetchWithInit, resourceMetadataUrl: this._resourceMetadataUrl } + ); + const result = await auth(this._oauthProvider, { serverUrl: this._url, authorizationCode, + iss: issParam, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + skipIssuerMetadataValidation: this._skipIssuerMetadataValidation }); if (result !== 'AUTHORIZED') { throw new UnauthorizedError('Failed to authorize'); @@ -519,39 +891,83 @@ export class StreamableHTTPClientTransport implements Transport { async send( message: JSONRPCMessage | JSONRPCMessage[], - options?: { resumptionToken?: string; onresumptiontoken?: (token: string) => void } + options?: { + resumptionToken?: string; + onresumptiontoken?: (token: string) => void; + requestSignal?: AbortSignal; + onRequestStreamEnd?: () => void; + headers?: Readonly>; + } ): Promise { return this._send(message, options, false); } private async _send( message: JSONRPCMessage | JSONRPCMessage[], - options: { resumptionToken?: string; onresumptiontoken?: (token: string) => void } | undefined, - isAuthRetry: boolean + options: + | { + resumptionToken?: string; + onresumptiontoken?: (token: string) => void; + requestSignal?: AbortSignal; + onRequestStreamEnd?: () => void; + headers?: Readonly>; + } + | undefined, + isAuthRetry: boolean, + stepUpRetries = 0 ): Promise { try { const { resumptionToken, onresumptiontoken } = options || {}; if (resumptionToken) { - // If we have a last event ID, we need to reconnect the SSE stream - this._startOrAuthSse({ resumptionToken, replayMessageId: isJSONRPCRequest(message) ? message.id : undefined }).catch( - error => this.onerror?.(error) - ); + // If we have a last event ID, we need to reconnect the SSE stream. + // Thread `requestSignal` through so the resumed GET honours the + // same per-request abort as the original POST — modern-era + // cancel-via-stream-close routes through `requestSignal`, and + // without it a resumed long-running request would not cancel. + this._startOrAuthSse({ + resumptionToken, + replayMessageId: isJSONRPCRequest(message) ? message.id : undefined, + requestSignal: options?.requestSignal + }).catch(error => this.onerror?.(error)); return; } const headers = await this._commonHeaders(); + this._applyBodyDerivedHeaders(headers, message); + // Per-request additional headers (the Client passes SEP-2243 + // `Mcp-Param-*` here on a 2026-07-28 connection). Reserved + // standard/auth header names are skipped so a caller cannot + // accidentally override the body-derived or connection-level + // headers — `Headers.set` overwrites, so the only way to keep the + // transport-owned values authoritative is to refuse to write over + // them here. + if (options?.headers !== undefined) { + for (const [name, value] of Object.entries(options.headers)) { + if (RESERVED_REQUEST_HEADER_NAMES.has(name.toLowerCase())) continue; + headers.set(name, value); + } + } 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']; headers.set('accept', [...new Set(types)].join(', ')); + // Per-request abort: when the caller supplies a request-scoped + // signal (the `subscriptions/listen` driver), aborting it cancels + // this POST and its SSE response stream without closing the + // transport. + const transportSignal = this._abortController?.signal; + const signal = + options?.requestSignal !== undefined && transportSignal !== undefined + ? anySignal(transportSignal, options.requestSignal) + : (options?.requestSignal ?? transportSignal); const init = { ...this._requestInit, method: 'POST', headers, body: JSON.stringify(message), - signal: this._abortController?.signal + signal }; const response = await (this._fetch ?? fetch)(this._url, init); @@ -568,7 +984,9 @@ export class StreamableHTTPClientTransport implements Transport { if (response.headers.has('www-authenticate')) { const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); this._resourceMetadataUrl = resourceMetadataUrl; - this._scope = scope; + // Preserve any union accumulated by `_stepUpAuthorize` so a 401 + // mid-chain does not narrow `_scope` back to the challenge value. + this._scope = computeScopeUnion(this._scope, scope); } if (this._authProvider.onUnauthorized && !isAuthRetry) { @@ -579,7 +997,7 @@ export class StreamableHTTPClientTransport implements Transport { }); await response.text?.().catch(() => {}); // Purposely _not_ awaited, so we don't call onerror twice - return this._send(message, options, true); + return this._send(message, options, true, stepUpRetries); } await response.text?.().catch(() => {}); if (isAuthRetry) { @@ -593,43 +1011,48 @@ export class StreamableHTTPClientTransport implements Transport { const text = await response.text?.().catch(() => null); - if (response.status === 403 && this._oauthProvider) { - const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response); + if (response.status === 403) { + const { resourceMetadataUrl, scope, error, errorDescription } = extractWWWAuthenticateParams(response); if (error === 'insufficient_scope') { - const wwwAuthHeader = response.headers.get('WWW-Authenticate'); - - // Check if we've already tried upscoping with this header to prevent infinite loops. - if (this._lastUpscopingHeader === wwwAuthHeader) { - throw new SdkHttpError(SdkErrorCode.ClientHttpForbidden, 'Server returned 403 after trying upscoping', { - status: 403, - statusText: response.statusText, - text - }); - } - - if (scope) { - this._scope = scope; - } - - if (resourceMetadataUrl) { - this._resourceMetadataUrl = resourceMetadataUrl; - } - - // Mark that upscoping was tried. - this._lastUpscopingHeader = wwwAuthHeader ?? undefined; - const result = await auth(this._oauthProvider, { - serverUrl: this._url, - resourceMetadataUrl: this._resourceMetadataUrl, - scope: this._scope, - fetchFn: this._fetchWithInit - }); - + const result = await this._stepUpAuthorize( + { scope, resourceMetadataUrl, errorDescription, statusText: response.statusText, text }, + stepUpRetries + ); if (result !== 'AUTHORIZED') { throw new UnauthorizedError(); } + return this._send(message, options, isAuthRetry, stepUpRetries + 1); + } + } - return this._send(message, options, isAuthRetry); + // SEP-2243 (and the rest of the inbound validation ladder) + // emit ladder rejections as HTTP 400 carrying a JSON-RPC error + // response body. Surface those in-band so `Protocol._onresponse` + // converts them to a typed `ProtocolError` matched to the + // pending request id — instead of an opaque transport error. + // Any 400 whose body is not a well-formed JSON-RPC error + // response (or whose id does not match an outstanding request) + // still falls through to the generic `SdkHttpError`. + // + // Modern-era only: gated on the outbound message carrying a + // 2026-07-28 envelope claim (the same gate the body-derived + // `mcp-method`/`mcp-name` headers use), so a legacy-era + // exchange keeps surfacing 400 as `SdkHttpError` exactly as + // before — the changeset's "legacy-era paths are unchanged" + // claim stays true and existing + // `e instanceof SdkHttpError && e.status === 400` callers do + // not silently stop matching. + if (response.status === 400 && typeof text === 'string' && this._isModernEnvelopedRequest(message)) { + try { + const parsed = JSONRPCMessageSchema.parse(JSON.parse(text)); + const requests = (Array.isArray(message) ? message : [message]).filter(m => isJSONRPCRequest(m)); + if (isJSONRPCErrorResponse(parsed) && requests.some(r => r.id === parsed.id)) { + this.onmessage?.(parsed); + return; + } + } catch { + // not a JSON-RPC error body — fall through to the generic SdkHttpError below. } } @@ -640,8 +1063,6 @@ export class StreamableHTTPClientTransport implements Transport { }); } - this._lastUpscopingHeader = undefined; - // If the response is 202 Accepted, there's no body to process if (response.status === 202) { await response.text?.().catch(() => {}); @@ -667,7 +1088,15 @@ export class StreamableHTTPClientTransport implements Transport { // Handle SSE stream responses for requests // We use the same handler as standalone streams, which now supports // reconnection with the last event ID - this._handleSseStream(response.body, { onresumptiontoken }, false); + this._handleSseStream( + response.body, + { + onresumptiontoken, + requestSignal: options?.requestSignal, + onRequestStreamEnd: options?.onRequestStreamEnd + }, + false + ); } else if (contentType?.includes('application/json')) { // For non-streaming servers, we might get direct JSON responses const data = await response.json(); @@ -689,7 +1118,15 @@ export class StreamableHTTPClientTransport implements Transport { await response.text?.().catch(() => {}); } } catch (error) { - this.onerror?.(error as Error); + // Intentional per-request abort BEFORE response headers (the + // `subscriptions/listen` driver aborting its `requestSignal`): + // fetch rejects with AbortError. Same guard as + // `_handleSseStream`'s `isIntentionalAbort` — do not surface a + // misleading onerror; still rethrow so `listen()`'s send-catch + // settles the per-subscription state machine. + if (options?.requestSignal?.aborted !== true) { + this.onerror?.(error as Error); + } throw error; } } diff --git a/packages/client/src/client/versionNegotiation.ts b/packages/client/src/client/versionNegotiation.ts new file mode 100644 index 0000000000..a791fdc57c --- /dev/null +++ b/packages/client/src/client/versionNegotiation.ts @@ -0,0 +1,422 @@ +/** + * 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 { + codecForVersion, + isJSONRPCErrorResponse, + isJSONRPCResultResponse, + isModernProtocolVersion, + legacyProtocolVersions, + modernProtocolVersions, + 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; + + /** + * Number of times to re-send the probe after a timeout before reaching the + * timeout verdict. Governs timeout re-sends only — the spec-mandated + * `-32022` corrective continuation (select-and-continue with a mutual + * version) is a separate negotiation step and is never counted against + * `maxRetries`. + * + * @default 0 (no retries) + */ + maxRetries?: 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: { + // The era codec owns the keyed-envelope shape; the probe is sent + // for a modern version, so this is always the 2026 envelope. + _meta: codecForVersion(protocolVersion).outboundEnvelope({ + protocolVersion, + clientInfo, + clientCapabilities: 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 maxRetries = Math.max(0, negotiation.probe.maxRetries ?? 0); + 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 -32022 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; + // `maxRetries` governs timeout re-sends only — independent of (and + // never counted against) the corrective continuation. + let timeoutRetriesRemaining = maxRetries; + for (;;) { + const reply = await window.exchange( + id => buildProbeRequest(id, requestedVersion, deps.clientInfo, deps.capabilities), + timeoutMs + ); + + if (reply.kind === 'timeout' && timeoutRetriesRemaining > 0) { + timeoutRetriesRemaining--; + continue; + } + + 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..2ed3f062dc 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -8,16 +8,20 @@ export type { AddClientAuthentication, + AuthOptions, AuthProvider, AuthResult, ClientAuthMethod, + OAuthClientInformationContext, OAuthClientProvider, OAuthDiscoveryState, OAuthServerInfo } from './client/auth.js'; export { + assertSecureTokenEndpoint, auth, buildDiscoveryUrls, + computeScopeUnion, discoverAuthorizationServerMetadata, discoverOAuthMetadata, discoverOAuthProtectedResourceMetadata, @@ -27,16 +31,27 @@ export { extractWWWAuthenticateParams, fetchToken, isHttpsUrl, + isStrictScopeSuperset, parseErrorResponse, prepareAuthorizationCodeRequest, refreshAuthorization, registerClient, + resolveClientMetadata, selectClientAuthMethod, selectResourceURL, startAuthorization, UnauthorizedError, + validateAuthorizationResponseIssuer, validateClientMetadataUrl } from './client/auth.js'; +export { + AuthorizationServerMismatchError, + InsecureTokenEndpointError, + InsufficientScopeError, + IssuerMismatchError, + OAuthClientFlowError, + RegistrationRejectedError +} from './client/authErrors.js'; export type { AssertionCallback, ClientCredentialsProviderOptions, @@ -52,15 +67,26 @@ export { PrivateKeyJwtProvider, StaticPrivateKeyJwtProvider } from './client/authExtensions.js'; -export type { ClientOptions } from './client/client.js'; +export type { CacheableRequestOptions, CallToolRequestOptions, ClientOptions, ConnectOptions, McpSubscription } from './client/client.js'; export { Client } from './client/client.js'; export { getSupportedElicitationModes } from './client/client.js'; export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, RequestJwtAuthGrantOptions } from './client/crossAppAccess.js'; export { discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from './client/crossAppAccess.js'; export type { LoggingOptions, Middleware, RequestLogger } from './client/middleware.js'; export { applyMiddlewares, createMiddleware, withLogging, withOAuth } from './client/middleware.js'; +export type { + CacheEntry, + CacheKey, + CacheMode, + CacheScope, + InMemoryResponseCacheStoreOptions, + MaybePromise, + ResponseCacheStore +} from './client/responseCache.js'; +export { InMemoryResponseCacheStore, MAX_CACHE_TTL_MS } from './client/responseCache.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 { @@ -74,5 +100,11 @@ export { StreamableHTTPClientTransport } from './client/streamableHttp.js'; // runtime-aware wrapper (shadows core/public's fromJsonSchema with optional validator) export { fromJsonSchema } from './fromJsonSchema.js'; +// Multi-round-trip requests (protocol revision 2026-07-28): the client-side +// auto-fulfilment knobs (ClientOptions.inputRequired) and the manual-mode +// schema wrapper for callers that opt out of auto-fulfilment per call. +export type { InputRequiredOptions } from '@modelcontextprotocol/core'; +export { withInputRequired } from '@modelcontextprotocol/core'; + // re-export curated public API from core export * from '@modelcontextprotocol/core/public'; diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 04d7f4a3fb..5a65a76711 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -1,27 +1,47 @@ -import type { AuthorizationServerMetadata, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/core'; +import type { + AuthorizationServerMetadata, + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthTokens, + StoredOAuthClientInformation, + StoredOAuthTokens +} from '@modelcontextprotocol/core'; import { LATEST_PROTOCOL_VERSION, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/core'; import type { Mock } from 'vitest'; import { expect, vi } from 'vitest'; import type { OAuthClientProvider } from '../../src/client/auth.js'; import { + assertSecureTokenEndpoint, auth, + AuthorizationServerMismatchError, buildDiscoveryUrls, + computeScopeUnion, determineScope, + discardIfIssuerMismatch, discoverAuthorizationServerMetadata, discoverOAuthMetadata, discoverOAuthProtectedResourceMetadata, discoverOAuthServerInfo, exchangeAuthorization, extractWWWAuthenticateParams, + InsecureTokenEndpointError, isHttpsUrl, + isStrictScopeSuperset, + IssuerMismatchError, refreshAuthorization, registerClient, + RegistrationRejectedError, + resolveAuthorizationCallbackParams, + resolveClientMetadata, selectClientAuthMethod, startAuthorization, + UnauthorizedError, + validateAuthorizationResponseIssuer, validateClientMetadataUrl } from '../../src/client/auth.js'; -import { createPrivateKeyJwtAuth } from '../../src/client/authExtensions.js'; +import type { OAuthClientInformationContext, OAuthDiscoveryState } from '../../src/client/auth.js'; +import { ClientCredentialsProvider, createPrivateKeyJwtAuth } from '../../src/client/authExtensions.js'; // Mock pkce-challenge vi.mock('pkce-challenge', () => ({ @@ -134,6 +154,62 @@ describe('OAuth Authorization', () => { expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ error: 'insufficient_scope', scope: 'admin' }); }); + + it('returns error_description when present', async () => { + const mockResponse = { + headers: { + get: vi.fn(name => + name === 'WWW-Authenticate' + ? `Bearer error="insufficient_scope", scope="admin", error_description="needs admin"` + : null + ) + } + } as unknown as Response; + + expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ + error: 'insufficient_scope', + scope: 'admin', + errorDescription: 'needs admin' + }); + }); + }); + + describe('computeScopeUnion', () => { + it.each([ + { inputs: [undefined], expected: undefined }, + { inputs: [undefined, undefined], expected: undefined }, + { inputs: ['', ' '], expected: undefined }, + { inputs: ['read'], expected: 'read' }, + { inputs: ['read', undefined], expected: 'read' }, + { inputs: ['read write', 'write admin'], expected: 'read write admin' }, + { inputs: ['read', 'read'], expected: 'read' }, + { inputs: [' read write ', 'admin'], expected: 'read write admin' }, + { inputs: ['a b', 'c', 'b d'], expected: 'a b c d' } + ])('union of $inputs is $expected', ({ inputs, expected }) => { + expect(computeScopeUnion(...inputs)).toBe(expected); + }); + + it('does not collapse hierarchical scopes', () => { + // The spec explicitly does not require clients to deduplicate + // hierarchically; the AS normalizes redundancy. + expect(computeScopeUnion('admin', 'read')).toBe('admin read'); + }); + }); + + describe('isStrictScopeSuperset', () => { + it.each([ + { union: undefined, current: undefined, expected: false }, + { union: undefined, current: 'read', expected: false }, + { union: 'read', current: undefined, expected: true }, + { union: 'read', current: '', expected: true }, + { union: 'read', current: 'read', expected: false }, + { union: 'read write', current: 'read', expected: true }, + { union: 'read write', current: 'read write', expected: false }, + { union: 'read write', current: 'write read admin', expected: false }, + { union: 'read', current: 'read write', expected: false } + ])('isStrictScopeSuperset($union, $current) is $expected', ({ union, current, expected }) => { + expect(isStrictScopeSuperset(union, current)).toBe(expected); + }); }); describe('discoverOAuthProtectedResourceMetadata', () => { @@ -883,6 +959,7 @@ describe('OAuth Authorization', () => { }; it('tries URLs in order and returns first successful metadata', async () => { + const tenantOidcMetadata = { ...validOpenIdMetadata, issuer: 'https://auth.example.com/tenant1' }; // First OAuth URL (path before well-known) fails with 404 mockFetch.mockResolvedValueOnce({ ok: false, @@ -893,12 +970,12 @@ describe('OAuth Authorization', () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200, - json: async () => validOpenIdMetadata + json: async () => tenantOidcMetadata }); const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com/tenant1'); - expect(metadata).toEqual(validOpenIdMetadata); + expect(metadata).toEqual(tenantOidcMetadata); // Verify it tried the URLs in the correct order const calls = mockFetch.mock.calls; @@ -919,11 +996,28 @@ describe('OAuth Authorization', () => { json: async () => validOpenIdMetadata }); - const metadata = await discoverAuthorizationServerMetadata('https://mcp.example.com'); + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com'); expect(metadata).toEqual(validOpenIdMetadata); }); + it('preserves authorization_response_iss_parameter_supported through OIDC discovery parse', async () => { + // OAuth well-known 404s; OIDC well-known returns metadata advertising RFC 9207 support. + // Regression-guard: OpenIdProviderDiscoveryMetadataSchema is a plain z.object(), so the + // field must be declared on the underlying schemas or it gets stripped — making the + // RFC 9207 §2.4 advertised-but-missing reject inert on the OIDC-only discovery path. + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ...validOpenIdMetadata, authorization_response_iss_parameter_supported: true }) + }); + + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com'); + + expect(metadata?.authorization_response_iss_parameter_supported).toBe(true); + }); + it('continues on 502 and tries next URL', async () => { // First URL (OAuth) returns 502 (reverse proxy with no route) mockFetch.mockResolvedValueOnce({ @@ -1050,6 +1144,172 @@ describe('OAuth Authorization', () => { // Only one call — no CORS retry attempted in non-browser environments expect(mockFetch).toHaveBeenCalledTimes(1); }); + + describe('RFC 8414 §3.3 issuer-echo validation', () => { + it('rejects metadata whose issuer does not match the discovery input', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ...validOAuthMetadata, issuer: 'https://honest.example.com' }) + }); + + const err = await discoverAuthorizationServerMetadata('https://attacker.example.com').catch(e => e); + expect(err).toBeInstanceOf(IssuerMismatchError); + expect(err).not.toBeInstanceOf(OAuthError); + expect(err.kind).toBe('metadata'); + expect(err.expected).toBe('https://attacker.example.com'); + expect(err.received).toBe('https://honest.example.com'); + }); + + it('rejects when metadata issuer matches a different tenant on the same host', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ...validOAuthMetadata, issuer: 'https://auth.example.com/tenant2' }) + }); + + await expect(discoverAuthorizationServerMetadata('https://auth.example.com/tenant1')).rejects.toThrow(IssuerMismatchError); + }); + + it('accepts when issuer matches the discovery input exactly', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOAuthMetadata + }); + + await expect(discoverAuthorizationServerMetadata('https://auth.example.com')).resolves.toEqual(validOAuthMetadata); + }); + + it('tolerates a trailing slash on the SDK-synthesized discovery input only', async () => { + // The legacy-fallback path synthesizes `String(new URL('/', serverUrl))` which always ends in `/`. + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOAuthMetadata // issuer: 'https://auth.example.com' + }); + await expect(discoverAuthorizationServerMetadata('https://auth.example.com/')).resolves.toEqual(validOAuthMetadata); + + // The tolerance is one-directional: a slash on the *received* side is still a mismatch. + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ...validOAuthMetadata, issuer: 'https://auth.example.com/' }) + }); + await expect(discoverAuthorizationServerMetadata('https://auth.example.com')).rejects.toThrow(IssuerMismatchError); + }); + + it('skipIssuerValidation suppresses the check', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ...validOAuthMetadata, issuer: 'https://honest.example.com' }) + }); + + await expect( + discoverAuthorizationServerMetadata('https://attacker.example.com', { skipIssuerValidation: true }) + ).resolves.toMatchObject({ issuer: 'https://honest.example.com' }); + }); + }); + }); + + describe('validateAuthorizationResponseIssuer', () => { + const expectedIssuer = 'https://auth.example.com'; + + // The spec's four-row decision table. + it.each([ + { label: 'row 1: supported + present + match → proceed', supported: true, iss: expectedIssuer, throws: false }, + { label: 'row 1: supported + present + mismatch → reject', supported: true, iss: 'https://attacker.example', throws: true }, + { label: 'row 2: supported + absent → reject', supported: true, iss: undefined, throws: true }, + { label: 'row 3: not advertised + present + match → proceed', supported: false, iss: expectedIssuer, throws: false }, + { + label: 'row 3: not advertised + present + mismatch → reject', + supported: false, + iss: 'https://attacker.example', + throws: true + }, + { label: 'row 4: not advertised + absent → proceed', supported: false, iss: undefined, throws: false } + ])('$label', ({ supported, iss, throws }) => { + const run = () => validateAuthorizationResponseIssuer({ iss, expectedIssuer, issParameterSupported: supported }); + if (throws) { + expect(run).toThrow(IssuerMismatchError); + try { + run(); + } catch (e) { + expect((e as IssuerMismatchError).kind).toBe('authorization_response'); + } + } else { + expect(run).not.toThrow(); + } + }); + + // Forbidden normalizations: every one of these MUST be a mismatch even though + // the values are URL-equivalent under RFC 3986 §6.2.2-6.2.3. + it.each([ + { label: 'scheme case', iss: 'HTTPS://auth.example.com' }, + { label: 'host case', iss: 'https://AUTH.example.com' }, + { label: 'default port elision', iss: 'https://auth.example.com:443' }, + { label: 'trailing slash', iss: 'https://auth.example.com/' }, + { label: 'percent-encoding', iss: 'https://auth.example.co%6D' } + ])('rejects on $label difference (no normalization applied)', ({ iss }) => { + expect(() => validateAuthorizationResponseIssuer({ iss, expectedIssuer, issParameterSupported: true })).toThrow( + IssuerMismatchError + ); + }); + + it('no-ops when there is no recorded issuer (no validated metadata)', () => { + expect(() => + validateAuthorizationResponseIssuer({ iss: 'https://anything', expectedIssuer: undefined, issParameterSupported: true }) + ).not.toThrow(); + expect(() => + validateAuthorizationResponseIssuer({ iss: undefined, expectedIssuer: undefined, issParameterSupported: true }) + ).not.toThrow(); + }); + + it('IssuerMismatchError JSON-encodes received value (log-injection guard)', () => { + const err = new IssuerMismatchError('authorization_response', expectedIssuer, 'https://a\nINFO: forged'); + expect(err.message).not.toContain('\nINFO'); + expect(err.message).toContain(String.raw`https://a\nINFO: forged`); + }); + }); + + describe('resolveAuthorizationCallbackParams', () => { + const issuer = 'https://auth.example.com'; + const provider = { + discoveryState: async () => ({ + authorizationServerMetadata: { + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + response_types_supported: ['code'], + authorization_response_iss_parameter_supported: true + } + }) + } as unknown as OAuthClientProvider; + + it('treats an empty ?code= as no-code (falls through to the error/neither diagnostic)', async () => { + // URLSearchParams.get('code') returns '' (not null) for `?code=`, so a `!== null` + // check would have POSTed `code=` to the token endpoint and lost the explicit + // diagnostic. The truthy check restores the pre-PR behavior. + await expect( + resolveAuthorizationCallbackParams(new URLSearchParams(`code=&state=x&iss=${issuer}`), undefined, provider, issuer) + ).rejects.toThrow(UnauthorizedError); + // With an `error` param present, surfaces the gated OAuthError instead. + await expect( + resolveAuthorizationCallbackParams( + new URLSearchParams(`code=&error=access_denied&iss=${issuer}`), + undefined, + provider, + issuer + ) + ).rejects.toThrow(OAuthError); + }); + + it('returns {authorizationCode, iss} when a non-empty code is present', async () => { + await expect( + resolveAuthorizationCallbackParams(new URLSearchParams(`code=abc&iss=${issuer}`), undefined, provider, issuer) + ).resolves.toEqual({ authorizationCode: 'abc', iss: issuer }); + }); }); describe('discoverOAuthServerInfo', () => { @@ -2024,6 +2284,11 @@ describe('OAuth Authorization', () => { ...validClientMetadata }; + function lastRegisterBody(): Record { + const call = mockFetch.mock.calls.at(-1); + return JSON.parse(call![1].body as string) as Record; + } + it('registers client and returns client information', async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -2037,17 +2302,13 @@ describe('OAuth Authorization', () => { expect(clientInfo).toEqual(validClientInfo); expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ - href: 'https://auth.example.com/register' - }), - expect.objectContaining({ - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(validClientMetadata) - }) + expect.objectContaining({ href: 'https://auth.example.com/register' }), + expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' } }) ); + expect(lastRegisterBody()).toMatchObject({ + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }); }); it('includes scope in registration body when provided, overriding clientMetadata.scope', async () => { @@ -2074,17 +2335,58 @@ describe('OAuth Authorization', () => { expect(clientInfo).toEqual(expectedClientInfo); expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ - href: 'https://auth.example.com/register' - }), - expect.objectContaining({ - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ ...validClientMetadata, scope: 'openid profile' }) - }) + expect.objectContaining({ href: 'https://auth.example.com/register' }), + expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' } }) ); + expect(lastRegisterBody()).toMatchObject({ ...validClientMetadata, scope: 'openid profile' }); + }); + + it('POSTs the supplied clientMetadata verbatim (defaults are applied upstream by resolveClientMetadata)', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => validClientInfo }); + await registerClient('https://auth.example.com', { + clientMetadata: resolveClientMetadata({ + clientMetadata: validClientMetadata, + redirectUrl: 'http://localhost:3000/callback' + }) + }); + expect(lastRegisterBody()).toMatchObject({ + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client', + application_type: 'native', + grant_types: ['authorization_code', 'refresh_token'] + }); + }); + + it('tolerates a non-enum application_type echoed by the AS (passes through, no throw)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ...validClientInfo, application_type: 'service' }) + }); + const info = await registerClient('https://auth.example.com', { clientMetadata: validClientMetadata }); + expect(info.application_type).toBe('service'); + }); + + it('throws RegistrationRejectedError carrying status, body, and submitted metadata on rejection', async () => { + const errorBody = JSON.stringify({ error: 'invalid_redirect_uri', error_description: 'http not permitted for web' }); + mockFetch.mockResolvedValueOnce(new Response(errorBody, { status: 400 })); + + const submitted = resolveClientMetadata({ + clientMetadata: { client_name: 't', redirect_uris: ['https://app.example.com/cb'] }, + redirectUrl: 'https://app.example.com/cb' + }); + const err = await registerClient('https://auth.example.com', { clientMetadata: submitted }).catch(e => e as unknown); + + expect(err).toBeInstanceOf(RegistrationRejectedError); + expect(err).not.toBeInstanceOf(OAuthError); + const rre = err as RegistrationRejectedError; + expect(rre.status).toBe(400); + expect(rre.body).toBe(errorBody); + expect(JSON.parse(rre.body).error).toBe(OAuthErrorCode.InvalidRedirectUri); + // The submitted metadata echoes what was sent — including SDK-applied defaults. + expect(rre.submittedMetadata.application_type).toBe('web'); + expect(rre.submittedMetadata.grant_types).toEqual(['authorization_code', 'refresh_token']); + expect(rre.submittedMetadata.redirect_uris).toEqual(['https://app.example.com/cb']); }); it('validates client information response schema', async () => { @@ -2131,7 +2433,130 @@ describe('OAuth Authorization', () => { registerClient('https://auth.example.com', { clientMetadata: validClientMetadata }) - ).rejects.toThrow('Dynamic client registration failed'); + ).rejects.toThrow(/Dynamic client registration failed/i); + }); + }); + + describe('SEP-2207: token-endpoint https guard', () => { + const clientInformation = { client_id: 'client123', client_secret: 'secret123' }; + + it('assertSecureTokenEndpoint: throws on non-loopback http, returns URL for loopback', () => { + expect(() => assertSecureTokenEndpoint('http://10.0.0.5/token')).toThrow(InsecureTokenEndpointError); + expect(assertSecureTokenEndpoint('http://127.0.0.1:3000/token')).toBeInstanceOf(URL); + }); + + it('rejects a non-https token_endpoint before sending credentials', async () => { + await expect( + exchangeAuthorization('https://auth.example.com', { + metadata: { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'http://auth.example.com/token', + response_types_supported: ['code'] + }, + clientInformation, + authorizationCode: 'code', + codeVerifier: 'verifier', + redirectUri: 'http://localhost:3000/callback' + }) + ).rejects.toThrow(InsecureTokenEndpointError); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('rejects when the authorization-server URL fallback resolves to non-https', async () => { + await expect(refreshAuthorization('http://auth.example.com', { clientInformation, refreshToken: 'rt' })).rejects.toThrow( + InsecureTokenEndpointError + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('propagates through auth() on the refresh branch instead of falling through to /authorize', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ ok: false, status: 404 }); + } + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://api.example.com', + authorization_endpoint: 'https://api.example.com/authorize', + token_endpoint: 'http://api.example.com/token', + response_types_supported: ['code'] + }) + }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + const redirectToAuthorization = vi.fn(); + const provider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { redirect_uris: ['http://localhost:3000/callback'] }; + }, + clientInformation: () => clientInformation, + tokens: () => ({ access_token: 'old', token_type: 'Bearer', refresh_token: 'rt' }), + saveTokens: vi.fn(), + redirectToAuthorization, + saveCodeVerifier: vi.fn(), + codeVerifier: () => 'v' + }; + + await expect(auth(provider, { serverUrl: 'https://api.example.com/mcp' })).rejects.toThrow(InsecureTokenEndpointError); + expect(redirectToAuthorization).not.toHaveBeenCalled(); + expect(mockFetch.mock.calls.some(c => c[0].toString().includes('/token'))).toBe(false); + }); + + it.each(['http://localhost:9001/token', 'http://127.0.0.1:9001/token', 'http://[::1]:9001/token'])( + 'permits loopback host %s', + async tokenEndpoint => { + mockFetch.mockResolvedValueOnce(Response.json({ access_token: 't', token_type: 'Bearer' })); + await expect( + refreshAuthorization('http://localhost:9001', { + metadata: { + issuer: 'http://localhost:9001', + authorization_endpoint: 'http://localhost:9001/authorize', + token_endpoint: tokenEndpoint, + response_types_supported: ['code'] + }, + clientInformation, + refreshToken: 'rt' + }) + ).resolves.toBeDefined(); + } + ); + }); + + // SEP-2207 verify-only: behaviors already correct at the v2 baseline, + // pinned here so a regression fails CI rather than the conformance referee. + describe('SEP-2207: refresh-token guidance (verify-only pins)', () => { + const clientInformation = { client_id: 'client123', client_secret: 'secret123' }; + + it('does not assume a refresh_token is issued (optional in the token-response schema)', async () => { + mockFetch.mockResolvedValueOnce(Response.json({ access_token: 't', token_type: 'Bearer' })); + const tokens = await exchangeAuthorization('https://auth.example.com', { + clientInformation, + authorizationCode: 'code', + codeVerifier: 'verifier', + redirectUri: 'http://localhost:3000/callback' + }); + expect(tokens.refresh_token).toBeUndefined(); + }); + + it('keeps the prior refresh_token when the AS omits a replacement on refresh', async () => { + mockFetch.mockResolvedValueOnce(Response.json({ access_token: 'new', token_type: 'Bearer' })); + const tokens = await refreshAuthorization('https://auth.example.com', { clientInformation, refreshToken: 'rt-old' }); + expect(tokens.refresh_token).toBe('rt-old'); + }); + + it('adopts a rotated refresh_token when the AS returns one', async () => { + mockFetch.mockResolvedValueOnce(Response.json({ access_token: 'new', token_type: 'Bearer', refresh_token: 'rt-new' })); + const tokens = await refreshAuthorization('https://auth.example.com', { clientInformation, refreshToken: 'rt-old' }); + expect(tokens.refresh_token).toBe('rt-new'); }); }); @@ -2277,7 +2702,7 @@ describe('OAuth Authorization', () => { ok: true, status: 200, json: async () => ({ - issuer: 'https://auth.example.com', + issuer: 'https://resource.example.com', authorization_endpoint: 'https://auth.example.com/authorize', token_endpoint: 'https://auth.example.com/token', registration_endpoint: 'https://auth.example.com/register', @@ -2730,7 +3155,7 @@ describe('OAuth Authorization', () => { ok: true, status: 200, json: async () => ({ - issuer: 'https://auth.example.com', + issuer: 'https://api.example.com', authorization_endpoint: 'https://auth.example.com/authorize', token_endpoint: 'https://auth.example.com/token', response_types_supported: ['code'], @@ -2786,7 +3211,7 @@ describe('OAuth Authorization', () => { ok: true, status: 200, json: async () => ({ - issuer: 'https://auth.example.com', + issuer: 'https://api.example.com', authorization_endpoint: 'https://auth.example.com/authorize', token_endpoint: 'https://auth.example.com/token', response_types_supported: ['code'], @@ -2850,7 +3275,7 @@ describe('OAuth Authorization', () => { ok: true, status: 200, json: async () => ({ - issuer: 'https://auth.example.com', + issuer: 'https://api.example.com', authorization_endpoint: 'https://auth.example.com/authorize', token_endpoint: 'https://auth.example.com/token', response_types_supported: ['code'], @@ -3625,10 +4050,11 @@ describe('OAuth Authorization', () => { serverUrl: 'https://server.example.com' }); - // Should save URL-based client info - expect(mockProvider.saveClientInformation).toHaveBeenCalledWith({ - client_id: 'https://example.com/client-metadata.json' - }); + // Should save URL-based client info (stamped with the resolved issuer + ctx) + expect(mockProvider.saveClientInformation).toHaveBeenCalledWith( + { client_id: 'https://example.com/client-metadata.json', issuer: 'https://server.example.com' }, + { issuer: 'https://server.example.com' } + ); }); it('falls back to DCR when server does not support URL-based client IDs', async () => { @@ -3670,11 +4096,15 @@ describe('OAuth Authorization', () => { }); // Should save DCR client info - expect(mockProvider.saveClientInformation).toHaveBeenCalledWith({ - client_id: 'generated-uuid', - client_secret: 'generated-secret', - redirect_uris: ['http://localhost:3000/callback'] - }); + expect(mockProvider.saveClientInformation).toHaveBeenCalledWith( + { + client_id: 'generated-uuid', + client_secret: 'generated-secret', + redirect_uris: ['http://localhost:3000/callback'], + issuer: 'https://server.example.com' + }, + { issuer: 'https://server.example.com' } + ); }); it('throws an error when clientMetadataUrl is not an HTTPS URL', async () => { @@ -3826,11 +4256,15 @@ describe('OAuth Authorization', () => { }); // Should fall back to DCR - expect(mockProvider.saveClientInformation).toHaveBeenCalledWith({ - client_id: 'generated-uuid', - client_secret: 'generated-secret', - redirect_uris: ['http://localhost:3000/callback'] - }); + expect(mockProvider.saveClientInformation).toHaveBeenCalledWith( + { + client_id: 'generated-uuid', + client_secret: 'generated-secret', + redirect_uris: ['http://localhost:3000/callback'], + issuer: 'https://server.example.com' + }, + { issuer: 'https://server.example.com' } + ); }); }); @@ -3908,6 +4342,80 @@ describe('OAuth Authorization', () => { }); }); + describe('resolveClientMetadata', () => { + const resolve = (clientMetadata: OAuthClientMetadata) => + resolveClientMetadata({ clientMetadata, redirectUrl: 'http://localhost:3000/callback' }); + + describe('SEP-837: application_type heuristic default', () => { + it.each([ + ['http://localhost:3000/callback', 'native'], + ['http://127.0.0.1:8080/cb', 'native'], + ['http://[::1]:8080/cb', 'native'], + ['myapp://oauth/callback', 'native'], + ['com.example.app:/cb', 'native'], + ['https://app.example.com/callback', 'web'], + ['http://app.internal/callback', 'web'] + ])('derives application_type for redirect_uri %s → %s', (redirectUri, expected) => { + expect(resolve({ client_name: 't', redirect_uris: [redirectUri] }).application_type).toBe(expected); + }); + + it("derives 'native' when any one redirect_uri is loopback", () => { + const md = resolve({ client_name: 't', redirect_uris: ['https://app.example.com/cb', 'http://localhost:3000/cb'] }); + expect(md.application_type).toBe('native'); + }); + + it('never overwrites a consumer-set application_type', () => { + const md = resolve({ + client_name: 't', + redirect_uris: ['http://localhost:3000/callback'], + application_type: 'web' + }); + // Loopback would heuristically pick 'native'; the consumer's 'web' wins. + expect(md.application_type).toBe('web'); + }); + + it("defaults to 'web' when redirect_uris is empty / undefined", () => { + expect(resolve({ client_name: 't', redirect_uris: [] }).application_type).toBe('web'); + }); + }); + + describe('SEP-2207: grant_types default', () => { + it("defaults grant_types to ['authorization_code', 'refresh_token'] when omitted", () => { + const md = resolve({ client_name: 't', redirect_uris: ['http://localhost:3000/callback'] }); + expect(md.grant_types).toEqual(['authorization_code', 'refresh_token']); + }); + + it('never overwrites a consumer-set grant_types', () => { + const md = resolve({ + client_name: 't', + redirect_uris: ['http://localhost:3000/callback'], + grant_types: ['client_credentials'] + }); + expect(md.grant_types).toEqual(['client_credentials']); + }); + + it('leaves grant_types undefined for non-interactive providers (no redirectUrl)', () => { + const md = resolveClientMetadata({ + clientMetadata: { client_name: 't', redirect_uris: [] }, + redirectUrl: undefined + }); + expect(md.grant_types).toBeUndefined(); + }); + }); + + it('preserves all other consumer-set fields verbatim', () => { + const md = resolve({ + client_name: 'Test Client', + redirect_uris: ['http://localhost:3000/callback'], + scope: 'a b c', + token_endpoint_auth_method: 'none' + }); + expect(md.client_name).toBe('Test Client'); + expect(md.scope).toBe('a b c'); + expect(md.token_endpoint_auth_method).toBe('none'); + }); + }); + describe('determineScope', () => { const baseClientMetadata = { redirect_uris: ['http://localhost:3000/callback'], @@ -4116,7 +4624,7 @@ describe('OAuth Authorization', () => { expect(result).toBe('mcp:read mcp:write'); }); - it('does NOT augment when grant_types is undefined (respects OAuth defaults)', () => { + it('does NOT augment with offline_access when grant_types is undefined (respects OAuth defaults)', () => { const result = determineScope({ resourceMetadata: { resource: 'https://api.example.com/', @@ -4128,6 +4636,403 @@ describe('OAuth Authorization', () => { expect(result).toBe('mcp:read mcp:write'); }); + + it('auth() does not push statically-registered clients into offline_access + prompt=consent', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ ok: false, status: 404 }); + } + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://api.example.com', + authorization_endpoint: 'https://api.example.com/authorize', + token_endpoint: 'https://api.example.com/token', + response_types_supported: ['code'], + scopes_supported: ['mcp:read', 'offline_access'] + }) + }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + const redirectToAuthorization = vi.fn(); + const provider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { redirect_uris: ['http://localhost:3000/callback'], scope: 'mcp:read' }; + }, + clientInformation: () => ({ client_id: 'static' }), + tokens: () => undefined, + saveTokens: vi.fn(), + redirectToAuthorization, + saveCodeVerifier: vi.fn(), + codeVerifier: () => 'v' + }; + + const result = await auth(provider, { serverUrl: 'https://api.example.com/mcp' }); + expect(result).toBe('REDIRECT'); + const authorizationUrl = redirectToAuthorization.mock.calls[0]![0] as URL; + expect(authorizationUrl.searchParams.get('scope')).toBe('mcp:read'); + expect(authorizationUrl.searchParams.has('prompt')).toBe(false); + }); + }); + }); + + describe('SEP-2352: per-authorization-server credential isolation (issuer-stamped)', () => { + const AS_ONE = 'https://as-one.example.com'; + const AS_TWO = 'https://as-two.example.com'; + + const asMetadata = (issuer: string): AuthorizationServerMetadata => ({ + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + registration_endpoint: `${issuer}/register`, + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'] + }); + + function createMigratingFetch() { + let active = AS_ONE; + const registerCalls: string[] = []; + const tokenCalls: Array<{ issuer: string; body: URLSearchParams }> = []; + const fetchFn = async (url: string | URL, init?: RequestInit): Promise => { + const u = new URL(String(url)); + if (u.pathname.includes('/.well-known/oauth-protected-resource')) { + return Response.json({ resource: 'https://api.example.com/mcp', authorization_servers: [active] }); + } + if (u.pathname.includes('/.well-known/')) { + return Response.json(asMetadata(u.origin)); + } + if (u.pathname === '/register') { + registerCalls.push(u.origin); + return Response.json({ client_id: `cid-${u.host}`, client_secret: 's', redirect_uris: [] }, { status: 201 }); + } + if (u.pathname === '/token') { + const body = new URLSearchParams(String(init?.body)); + tokenCalls.push({ issuer: u.origin, body }); + return Response.json({ access_token: 'at', token_type: 'Bearer' }); + } + return new Response(null, { status: 404 }); + }; + return { fetchFn, registerCalls, tokenCalls, switchTo: (i: string) => (active = i) }; + } + + /** Single-slot blob provider — round-trips the SDK-stamped values verbatim. */ + function createBlobProvider(withDiscoveryState = true): OAuthClientProvider & { + redirected: URL[]; + stored: { info?: StoredOAuthClientInformation; tokens?: StoredOAuthTokens }; + } { + const stored: { info?: StoredOAuthClientInformation; tokens?: StoredOAuthTokens } = {}; + const redirected: URL[] = []; + let discovery: OAuthDiscoveryState | undefined; + let verifier: string | undefined; + return { + redirected, + stored, + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { client_name: 't', redirect_uris: ['http://localhost:3000/callback'] }; + }, + clientInformation: () => stored.info, + saveClientInformation: i => void (stored.info = i), + tokens: () => stored.tokens, + saveTokens: t => void (stored.tokens = t), + redirectToAuthorization: u => void redirected.push(u), + saveCodeVerifier: v => void (verifier = v), + codeVerifier: () => verifier ?? 'v', + ...(withDiscoveryState && { + saveDiscoveryState: (s: OAuthDiscoveryState) => void (discovery = s), + discoveryState: () => discovery, + invalidateCredentials: (s: string) => { + if (s === 'client' || s === 'all') stored.info = undefined; + if (s === 'tokens' || s === 'all') stored.tokens = undefined; + if (s === 'discovery' || s === 'all') discovery = undefined; + } + }) + }; + } + + it('discardIfIssuerMismatch: returns undefined only on a different stamp; warns on unstamped', () => { + const stamped: StoredOAuthTokens = { access_token: 'a', token_type: 'Bearer', issuer: AS_ONE }; + const unstamped: StoredOAuthTokens = { access_token: 'a', token_type: 'Bearer' }; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(discardIfIssuerMismatch(stamped, AS_ONE)).toBe(stamped); + expect(discardIfIssuerMismatch(stamped, AS_TWO)).toBeUndefined(); + expect(discardIfIssuerMismatch(unstamped, AS_TWO)).toBe(unstamped); + expect(discardIfIssuerMismatch(undefined, AS_TWO)).toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); + + it('clientInformation stamped for AS-one is discarded at AS-two → re-registers (single-slot blob provider)', async () => { + const srv = createMigratingFetch(); + const provider = createBlobProvider(); + + expect(await auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn })).toBe('REDIRECT'); + expect(provider.stored.info?.issuer).toBe(AS_ONE); + expect(srv.registerCalls).toEqual([AS_ONE]); + + srv.switchTo(AS_TWO); + provider.invalidateCredentials?.('discovery'); + expect(await auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn })).toBe('REDIRECT'); + expect(srv.registerCalls).toEqual([AS_ONE, AS_TWO]); + expect(provider.stored.info?.issuer).toBe(AS_TWO); + expect(provider.redirected.at(-1)?.origin).toBe(AS_TWO); + expect(provider.redirected.at(-1)?.searchParams.get('client_id')).toBe('cid-as-two.example.com'); + }); + + it('refresh_token stamped for AS-one is never POSTed to AS-two', async () => { + const srv = createMigratingFetch(); + const provider = createBlobProvider(); + provider.stored.info = { client_id: 'cid', issuer: AS_TWO }; + provider.stored.tokens = { access_token: 'at', token_type: 'Bearer', refresh_token: 'rt-one', issuer: AS_ONE }; + srv.switchTo(AS_TWO); + + expect(await auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn })).toBe('REDIRECT'); + for (const { issuer, body } of srv.tokenCalls) { + expect(issuer).not.toBe(AS_TWO); + expect(body.get('refresh_token')).not.toBe('rt-one'); + } + }); + + it('issuer-keyed provider holds independent credentials per AS', async () => { + const srv = createMigratingFetch(); + const map = new Map(); + const provider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { client_name: 't', redirect_uris: ['http://localhost:3000/callback'] }; + }, + clientInformation: (ctx?: OAuthClientInformationContext) => (ctx ? map.get(ctx.issuer) : undefined), + saveClientInformation: (i, ctx) => void (ctx && map.set(ctx.issuer, i)), + tokens: () => undefined, + saveTokens: () => {}, + redirectToAuthorization: () => {}, + saveCodeVerifier: () => {}, + codeVerifier: () => 'v' + }; + + await auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn }); + srv.switchTo(AS_TWO); + await auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn }); + expect(map.get(AS_ONE)?.client_id).toBe('cid-as-one.example.com'); + expect(map.get(AS_TWO)?.client_id).toBe('cid-as-two.example.com'); + }); + + it('callback-leg gate throws when discoveryState issuer differs from resolved issuer', async () => { + const srv = createMigratingFetch(); + const provider = createBlobProvider(); + provider.stored.info = { client_id: 'cid', issuer: AS_ONE }; + // Recorded redirect target = AS-one, but cached state lacks an authorizationServerUrl + // so authInternal runs fresh discovery → AS-two. + provider.saveDiscoveryState?.({ + authorizationServerUrl: '', + authorizationServerMetadata: asMetadata(AS_ONE) + } as OAuthDiscoveryState); + srv.switchTo(AS_TWO); + + await expect( + auth(provider, { serverUrl: 'https://api.example.com/mcp', authorizationCode: 'code', fetchFn: srv.fetchFn }) + ).rejects.toBeInstanceOf(AuthorizationServerMismatchError); + }); + + it('callback-leg gate fails closed when provider implements saveDiscoveryState but discoveryState() is undefined', async () => { + const srv = createMigratingFetch(); + const provider = createBlobProvider(); + provider.stored.info = { client_id: 'cid', issuer: AS_ONE }; + // Provider implements saveDiscoveryState/discoveryState, but the recorded state was + // lost (e.g. fresh process / page navigation between redirect and callback). The + // gate must fail closed rather than silently re-discover. + // (createBlobProvider starts with discoveryState() → undefined.) + const err = await auth(provider, { + serverUrl: 'https://api.example.com/mcp', + authorizationCode: 'code', + fetchFn: srv.fetchFn + }).then( + () => undefined, + e => e + ); + expect(err).toBeInstanceOf(AuthorizationServerMismatchError); + expect((err as AuthorizationServerMismatchError).recordedIssuer).toContain( + 'discoveryState was not available on the callback leg' + ); + expect(srv.tokenCalls).toHaveLength(0); + }); + + it('warns once on the callback leg when the provider has no discoveryState', async () => { + const srv = createMigratingFetch(); + const provider = createBlobProvider(false); + provider.stored.info = { client_id: 'cid', issuer: AS_ONE }; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await auth(provider, { + serverUrl: 'https://api.example.com/mcp', + authorizationCode: 'code', + iss: AS_ONE, + fetchFn: srv.fetchFn + }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain('saveDiscoveryState'); + warn.mockRestore(); + }); + + it('back-stamps a legacy unstamped clientInformation on first use after upgrade', async () => { + const srv = createMigratingFetch(); + const provider = createBlobProvider(); + // Pre-SEP-2352 storage: no `issuer` field on the stored blob. + provider.stored.info = { client_id: 'legacy-cid', client_secret: 'legacy-secret' }; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(await auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn })).toBe('REDIRECT'); + // First use binds the unstamped value to the resolved AS — closes the permanent window. + expect(provider.stored.info).toEqual({ client_id: 'legacy-cid', client_secret: 'legacy-secret', issuer: AS_ONE }); + // The legacy value was used, not re-registered. + expect(srv.registerCalls).toHaveLength(0); + expect(warn.mock.calls.some(c => String(c[0]).includes("no 'issuer' stamp"))).toBe(true); + warn.mockRestore(); + + // Subsequent call against AS-two now sees a stamped value and re-registers. + srv.switchTo(AS_TWO); + provider.invalidateCredentials?.('discovery'); + await auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn }); + expect(srv.registerCalls).toEqual([AS_TWO]); + }); + + it('back-stamps a legacy unstamped token set on first use', async () => { + const srv = createMigratingFetch(); + const provider = createBlobProvider(); + provider.stored.info = { client_id: 'cid', issuer: AS_ONE }; + provider.stored.tokens = { access_token: 'at', token_type: 'Bearer', refresh_token: 'rt-legacy' }; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn }); + // The unstamped token set is written back with the resolved issuer before refresh. + expect(provider.stored.tokens?.issuer).toBe(AS_ONE); + warn.mockRestore(); + }); + + it('callback-leg gate: saveDiscoveryState is NOT called when AuthorizationServerMismatchError throws', async () => { + // Case 1: cachedState undefined → fail-closed '(none recorded)' → fresh discovery + // result must NOT have been persisted (a retry would otherwise read it back as + // recordedIssuer and the gate would pass). + const srv = createMigratingFetch(); + const provider = createBlobProvider(); + const saveSpy = vi.fn(provider.saveDiscoveryState!); + provider.saveDiscoveryState = saveSpy; + provider.stored.info = { client_id: 'cid', issuer: AS_ONE }; + + await expect( + auth(provider, { serverUrl: 'https://api.example.com/mcp', authorizationCode: 'code', fetchFn: srv.fetchFn }) + ).rejects.toBeInstanceOf(AuthorizationServerMismatchError); + expect(saveSpy).not.toHaveBeenCalled(); + expect(provider.discoveryState?.()).toBeUndefined(); + + // Case 2: cachedState records AS-one (forces full discovery via empty + // authorizationServerUrl), discovery resolves AS-two → throw → AS-one record is + // untouched. + const srv2 = createMigratingFetch(); + const provider2 = createBlobProvider(); + provider2.stored.info = { client_id: 'cid', issuer: AS_ONE }; + provider2.saveDiscoveryState?.({ + authorizationServerUrl: '', + authorizationServerMetadata: asMetadata(AS_ONE) + } as OAuthDiscoveryState); + srv2.switchTo(AS_TWO); + + await expect( + auth(provider2, { serverUrl: 'https://api.example.com/mcp', authorizationCode: 'code', fetchFn: srv2.fetchFn }) + ).rejects.toBeInstanceOf(AuthorizationServerMismatchError); + expect((provider2.discoveryState?.() as OAuthDiscoveryState).authorizationServerMetadata?.issuer).toBe(AS_ONE); + }); + + it('callback-leg gate: trailing-slash difference between recorded fallback URL and metadata issuer is tolerated', async () => { + const srv = createMigratingFetch(); + const provider = createBlobProvider(); + provider.stored.info = { client_id: 'cid', issuer: AS_ONE }; + // Redirect leg recorded the SDK-derived String(URL) form (slash-suffixed) with no + // metadata; callback leg sees metadata.issuer (slash-free). Same AS — must not throw. + provider.saveDiscoveryState?.({ authorizationServerUrl: AS_ONE + '/' } as OAuthDiscoveryState); + + await expect( + auth(provider, { serverUrl: 'https://api.example.com/mcp', authorizationCode: 'code', iss: AS_ONE, fetchFn: srv.fetchFn }) + ).resolves.toBe('AUTHORIZED'); + }); + + it('discardIfIssuerMismatch: trailing-slash difference does not discard', () => { + const stamped = { client_id: 'x', issuer: 'https://as.example.com' }; + expect(discardIfIssuerMismatch(stamped, 'https://as.example.com/')).toBe(stamped); + expect(discardIfIssuerMismatch({ client_id: 'x', issuer: 'https://as.example.com/' }, 'https://as.example.com')).toBeDefined(); + }); + + it('invalid_client on code exchange does not surface AuthorizationServerMismatchError', async () => { + const base = createMigratingFetch(); + const fetchFn = async (url: string | URL, init?: RequestInit): Promise => { + if (new URL(String(url)).pathname === '/token') { + return Response.json({ error: 'invalid_client' }, { status: 400 }); + } + return base.fetchFn(url, init); + }; + const provider = createBlobProvider(); + provider.stored.info = { client_id: 'cid', issuer: AS_ONE }; + provider.saveDiscoveryState?.({ + authorizationServerUrl: AS_ONE, + authorizationServerMetadata: asMetadata(AS_ONE) + } as OAuthDiscoveryState); + + const err = await auth(provider, { + serverUrl: 'https://api.example.com/mcp', + authorizationCode: 'code', + iss: AS_ONE, + fetchFn + }).then( + () => undefined, + e => e + ); + // The retry surfaces the (comprehensible) missing-client-information error, not a + // false '(none recorded)' AS-change. + expect(err).not.toBeInstanceOf(AuthorizationServerMismatchError); + expect((provider.discoveryState?.() as OAuthDiscoveryState).authorizationServerUrl).toBe(AS_ONE); + }); + + it('ClientCredentialsProvider without expectedIssuer: no SEP-2352 warn on auth()', async () => { + const srv = createMigratingFetch(); + const provider = new ClientCredentialsProvider({ clientId: 'static', clientSecret: 's' }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn }); + await auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn }); + expect(warn.mock.calls.filter(c => /no 'issuer' stamp/.test(String(c[0])))).toHaveLength(0); + warn.mockRestore(); + }); + + it('m2m expectedIssuer: ClientCredentialsProvider refuses to send the credential to a different AS', async () => { + const srv = createMigratingFetch(); + srv.switchTo(AS_TWO); + const provider = new ClientCredentialsProvider({ clientId: 'static', clientSecret: 's', expectedIssuer: AS_ONE }); + + const err = await auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn }).then( + () => undefined, + e => e + ); + expect(err).toBeInstanceOf(AuthorizationServerMismatchError); + expect((err as AuthorizationServerMismatchError).recordedIssuer).toBe(AS_ONE); + expect((err as AuthorizationServerMismatchError).currentIssuer).toBe(AS_TWO); + expect(srv.tokenCalls.filter(c => c.issuer === AS_TWO)).toHaveLength(0); + + // Matching expectedIssuer proceeds. + srv.switchTo(AS_ONE); + const ok = new ClientCredentialsProvider({ clientId: 'static', clientSecret: 's', expectedIssuer: AS_ONE }); + expect(await auth(ok, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn })).toBe('AUTHORIZED'); }); }); }); 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/connectPrior.test.ts b/packages/client/test/client/connectPrior.test.ts new file mode 100644 index 0000000000..6eaec28985 --- /dev/null +++ b/packages/client/test/client/connectPrior.test.ts @@ -0,0 +1,198 @@ +/** + * `connect({ prior: DiscoverResult })` — zero-round-trip reconnect for the + * gateway / distributed-client pattern (issue #79). A previously-obtained + * `DiscoverResult` adopted directly: on a modern overlap nothing reaches the + * wire during connect; no modern overlap throws `EraNegotiationFailed` (no + * legacy fallback). Populates `getDiscoverResult()` (alongside the + * `'auto'`-mode probe path) and round-trips through JSON. + */ +import type { DiscoverResult, 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[] = []; + setProtocolVersionCalls: string[] = []; + + 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 { + this.setProtocolVersionCalls.push(version); + } + reply(message: JSONRPCMessage): void { + this.onmessage?.(message); + } +} + +const prior = (supportedVersions: string[]): DiscoverResult => ({ + supportedVersions, + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 'persisted-server', version: '1.0.0' }, + instructions: 'persisted instructions' +}); + +describe('connect({ prior }) — modern overlap: zero round trips', () => { + test('nothing reaches the wire during connect; era state is the post-probe state', async () => { + const transport = new ScriptedTransport(); + const client = new Client({ name: 'worker', version: '0' }); + + await client.connect(transport, { prior: prior([MODERN]) }); + + // ZERO requests sent during connect. + expect(transport.sent).toHaveLength(0); + // The transport's protocol-version slot is stamped exactly once. + expect(transport.setProtocolVersionCalls).toEqual([MODERN]); + // Adopted directly from prior. + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + expect(client.getProtocolEra()).toBe('modern'); + expect(client.getServerCapabilities()).toEqual({ tools: { listChanged: true } }); + expect(client.getServerVersion()).toEqual({ name: 'persisted-server', version: '1.0.0' }); + expect(client.getInstructions()).toBe('persisted instructions'); + expect(client.getDiscoverResult()).toEqual(prior([MODERN])); + + await client.close(); + }); + + test('callTool works immediately after a zero-round-trip connect', async () => { + const transport = new ScriptedTransport((message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'tools/call') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + result: { resultType: 'complete', content: [{ type: 'text', text: 'ok' }] } + }); + } + }); + const client = new Client({ name: 'worker', version: '0' }); + await client.connect(transport, { prior: prior([MODERN]) }); + + // First wire traffic is the tools/call itself. + expect(transport.sent).toHaveLength(0); + const result = await client.callTool({ name: 'echo' }); + expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' }); + const reqs = transport.sent.filter(isJSONRPCRequest); + expect(reqs).toHaveLength(1); + expect(reqs[0]!.method).toBe('tools/call'); + + await client.close(); + }); + + test('prior bypasses versionNegotiation resolution (no probe even with mode: auto)', async () => { + const transport = new ScriptedTransport(); + const client = new Client({ name: 'worker', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(transport, { prior: prior([MODERN]) }); + expect(transport.sent).toHaveLength(0); + await client.close(); + }); +}); + +describe('connect({ prior }) — no modern overlap: throws (no legacy fallback)', () => { + test('legacy-only prior → SdkError(EraNegotiationFailed) steering to mode: auto', async () => { + const transport = new ScriptedTransport(); + const client = new Client({ name: 'worker', version: '0' }); + await expect(client.connect(transport, { prior: prior(['2025-06-18']) })).rejects.toSatisfy( + error => + error instanceof SdkError && + error.code === SdkErrorCode.EraNegotiationFailed && + /2026-07-28\+ mutual/.test(error.message) && + /mode: 'auto'/.test(error.message) + ); + // Nothing reached the transport (the throw is before super.connect()). + expect(transport.sent).toHaveLength(0); + expect(client.getDiscoverResult()).toBeUndefined(); + }); + + test('disjoint modern lists → SdkError(EraNegotiationFailed)', async () => { + const transport = new ScriptedTransport(); + const client = new Client({ name: 'worker', version: '0' }); + await expect(client.connect(transport, { prior: prior(['2099-01-01']) })).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + expect(transport.sent).toHaveLength(0); + }); +}); + +describe('getDiscoverResult() round-trip', () => { + test("'auto'-mode probe populates it; JSON.stringify/parse round-trips into connect({ prior })", async () => { + // Bootstrap: a real probe against a scripted modern server. + const bootstrapTransport = new ScriptedTransport((message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + result: { + supportedVersions: [MODERN], + capabilities: { tools: {} }, + serverInfo: { name: 'probed-server', version: '2.0.0' } + } + }); + } + }); + const bootstrap = new Client({ name: 'bootstrap', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + await bootstrap.connect(bootstrapTransport); + const probed = bootstrap.getDiscoverResult(); + expect(probed?.serverInfo).toEqual({ name: 'probed-server', version: '2.0.0' }); + expect(probed?.supportedVersions).toEqual([MODERN]); + await bootstrap.close(); + // close() clears per-connection state. + expect(bootstrap.getDiscoverResult()).toBeUndefined(); + + // Persist + revive (the gateway pattern's "write to Redis/config" step). + const persisted = JSON.stringify(probed); + const revived = JSON.parse(persisted) as DiscoverResult; + + // Worker: zero-round-trip connect from the revived blob. + const workerTransport = new ScriptedTransport(); + const worker = new Client({ name: 'worker', version: '0' }); + await worker.connect(workerTransport, { prior: revived }); + expect(workerTransport.sent).toHaveLength(0); + expect(worker.getServerVersion()).toEqual({ name: 'probed-server', version: '2.0.0' }); + expect(worker.getDiscoverResult()).toEqual(revived); + await worker.close(); + }); + + test('discover() populates it on an already-connected modern client', async () => { + const transport = new ScriptedTransport((message, t) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'public', + supportedVersions: [MODERN], + capabilities: { tools: {} }, + serverInfo: { name: 'rediscovered', version: '3.0.0' } + } + }); + } + }); + const client = new Client({ name: 'c', version: '0' }); + await client.connect(transport, { prior: prior([MODERN]) }); + expect(client.getDiscoverResult()?.serverInfo.name).toBe('persisted-server'); + const fresh = await client.discover(); + expect(fresh.serverInfo.name).toBe('rediscovered'); + expect(client.getDiscoverResult()?.serverInfo.name).toBe('rediscovered'); + await client.close(); + }); +}); diff --git a/packages/client/test/client/crossAppAccess.test.ts b/packages/client/test/client/crossAppAccess.test.ts index 1b595c4daa..fa0385d49d 100644 --- a/packages/client/test/client/crossAppAccess.test.ts +++ b/packages/client/test/client/crossAppAccess.test.ts @@ -1,10 +1,49 @@ import type { FetchLike } from '@modelcontextprotocol/core'; import { describe, expect, it, vi } from 'vitest'; +import { InsecureTokenEndpointError } from '../../src/client/authErrors.js'; import { discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from '../../src/client/crossAppAccess.js'; describe('crossAppAccess', () => { describe('requestJwtAuthorizationGrant', () => { + it('rejects a non-https token endpoint before sending credentials (SEP-2207)', async () => { + const mockFetch = vi.fn(); + await expect( + requestJwtAuthorizationGrant({ + tokenEndpoint: 'http://idp.internal/token', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }) + ).rejects.toThrow(InsecureTokenEndpointError); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('permits a loopback http token endpoint (SEP-2207 exemption)', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + access_token: 'jag', + token_type: 'N_A' + }) + } as Response); + await expect( + requestJwtAuthorizationGrant({ + tokenEndpoint: 'http://localhost:3000/token', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'client', + fetchFn: mockFetch + }) + ).resolves.toMatchObject({ jwtAuthGrant: 'jag' }); + expect(mockFetch).toHaveBeenCalledOnce(); + }); + it('successfully exchanges ID token for JWT Authorization Grant', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, @@ -34,7 +73,7 @@ describe('crossAppAccess', () => { expect(mockFetch).toHaveBeenCalledOnce(); const [url, init] = mockFetch.mock.calls[0]!; - expect(url).toBe('https://idp.example.com/token'); + expect(String(url)).toBe('https://idp.example.com/token'); expect(init?.method).toBe('POST'); expect(init?.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' @@ -290,6 +329,20 @@ describe('crossAppAccess', () => { }); describe('exchangeJwtAuthGrant', () => { + it('rejects a non-https token endpoint before sending credentials (SEP-2207)', async () => { + const mockFetch = vi.fn(); + await expect( + exchangeJwtAuthGrant({ + tokenEndpoint: 'http://as.internal/token', + jwtAuthGrant: 'jwt', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }) + ).rejects.toThrow(InsecureTokenEndpointError); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it('exchanges JAG for access token using client_secret_basic by default', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, @@ -316,7 +369,7 @@ describe('crossAppAccess', () => { expect(mockFetch).toHaveBeenCalledOnce(); const [url, init] = mockFetch.mock.calls[0]!; - expect(url).toBe('https://auth.chat.example/token'); + expect(String(url)).toBe('https://auth.chat.example/token'); expect(init?.method).toBe('POST'); // SEP-990 conformance: credentials in Authorization header, NOT in body 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/envelopeAutoEmission.test.ts b/packages/client/test/client/envelopeAutoEmission.test.ts new file mode 100644 index 0000000000..307baa1a9e --- /dev/null +++ b/packages/client/test/client/envelopeAutoEmission.test.ts @@ -0,0 +1,248 @@ +/** + * Per-request `_meta` envelope auto-emission (protocol revision 2026-07-28): + * on a connection that negotiated the modern era — auto-negotiated or pinned — + * the client automatically attaches the reserved protocol-version / + * client-info / client-capabilities `_meta` keys to every outgoing request and + * notification. User-supplied `_meta` keys win over the auto-attached ones. + * Legacy-era connections never gain these keys (D9b byte-identity holds). + */ +import type { JSONRPCMessage } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + InMemoryTransport, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY +} 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)); + +function metaOf(message: JSONRPCMessage): Record | undefined { + const params = (message as { params?: { _meta?: Record } }).params; + return params?._meta; +} + +/** + * 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(era: 'modern' | 'legacy', answerToolsList = true) { + 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 (era === '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: { tools: {} }, + serverInfo: { name: 'scripted-legacy-server', version: '1.0.0' } + } + }); + return; + } + if (request.method === 'tools/list' && request.id !== undefined && answerToolsList) { + const result: Record = + era === 'modern' ? { resultType: 'complete', tools: [], ttlMs: 0, cacheScope: 'public' } : { tools: [] }; + void serverTx.send({ jsonrpc: '2.0', id: request.id, result }); + } + }; + await serverTx.start(); + return { clientTx, written }; +} + +describe('per-request _meta envelope auto-emission on modern-era connections', () => { + it('attaches the reserved envelope keys to every outgoing request and notification', async () => { + const { clientTx, written } = await scriptedServerSide('modern'); + const clientInfo = { name: 'envelope-client', version: '1.2.3' }; + const client = new Client(clientInfo, { + versionNegotiation: { mode: 'auto' }, + capabilities: { elicitation: { form: {} } } + }); + await client.connect(clientTx); + expect(client.getProtocolEra()).toBe('modern'); + + await client.listTools(); + await client.notification({ method: 'notifications/progress', params: { progressToken: 't', progress: 1 } }); + await flush(); + + const listToolsMessage = written.find(m => (m as { method?: string }).method === 'tools/list'); + expect(listToolsMessage).toBeDefined(); + expect(metaOf(listToolsMessage!)).toEqual({ + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: clientInfo, + [CLIENT_CAPABILITIES_META_KEY]: { elicitation: { form: {} } } + }); + + const progressMessage = written.find(m => (m as { method?: string }).method === 'notifications/progress'); + expect(progressMessage).toBeDefined(); + expect(metaOf(progressMessage!)?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN); + expect(metaOf(progressMessage!)?.[CLIENT_INFO_META_KEY]).toEqual(clientInfo); + + await client.close(); + }); + + it('reflects registered client capabilities in the auto-attached client-capabilities key', async () => { + const { clientTx, written } = await scriptedServerSide('modern'); + const client = new Client({ name: 'envelope-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + client.registerCapabilities({ sampling: {} }); + await client.connect(clientTx); + + await client.listTools(); + const listToolsMessage = written.find(m => (m as { method?: string }).method === 'tools/list'); + expect(metaOf(listToolsMessage!)?.[CLIENT_CAPABILITIES_META_KEY]).toEqual({ sampling: {} }); + + await client.close(); + }); + + it('user-supplied _meta keys win over the auto-attached envelope keys; non-envelope keys are preserved', async () => { + const { clientTx, written } = await scriptedServerSide('modern'); + const client = new Client({ name: 'envelope-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(clientTx); + + await client.request({ + method: 'tools/list', + params: { _meta: { [PROTOCOL_VERSION_META_KEY]: 'consumer-override', 'x-consumer': 'kept' } } + }); + const listToolsMessage = written.find(m => (m as { method?: string }).method === 'tools/list'); + const meta = metaOf(listToolsMessage!); + expect(meta?.[PROTOCOL_VERSION_META_KEY]).toBe('consumer-override'); + expect(meta?.['x-consumer']).toBe('kept'); + // The other envelope keys are still auto-attached. + expect(meta?.[CLIENT_INFO_META_KEY]).toEqual({ name: 'envelope-client', version: '1.0.0' }); + + await client.close(); + }); + + it('attaches the envelope to the cancellation notification of a modern-era request', async () => { + const { clientTx, written } = await scriptedServerSide('modern', /* answerToolsList */ false); + const client = new Client({ name: 'envelope-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(clientTx); + + const controller = new AbortController(); + const pending = client.listTools(undefined, { signal: controller.signal }).catch(() => {}); + await flush(); + controller.abort('test cancel'); + await pending; + await flush(); + + const cancelMessage = written.find(m => (m as { method?: string }).method === 'notifications/cancelled'); + expect(cancelMessage).toBeDefined(); + expect(metaOf(cancelMessage!)?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN); + + await client.close(); + }); + + it('legacy-era connections never gain the envelope keys (byte-identity with a 2025 client)', async () => { + const { clientTx, written } = await scriptedServerSide('legacy'); + const client = new Client({ name: 'envelope-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + expect(client.getProtocolEra()).toBe('legacy'); + + await client.listTools(); + await flush(); + + // initialize, notifications/initialized, tools/list — none carry envelope keys. + const postProbe = written.filter(m => (m as { method?: string }).method !== 'server/discover'); + expect(postProbe.length).toBeGreaterThanOrEqual(3); + for (const message of postProbe) { + const meta = metaOf(message); + expect(meta?.[PROTOCOL_VERSION_META_KEY]).toBeUndefined(); + expect(meta?.[CLIENT_INFO_META_KEY]).toBeUndefined(); + expect(meta?.[CLIENT_CAPABILITIES_META_KEY]).toBeUndefined(); + } + + await client.close(); + }); + + it('the plain legacy default (no versionNegotiation) emits no envelope keys at all', async () => { + const { clientTx, written } = await scriptedServerSide('legacy'); + const client = new Client({ name: 'envelope-client', version: '1.0.0' }); + await client.connect(clientTx); + expect(client.getProtocolEra()).toBe('legacy'); + + await client.listTools(); + await flush(); + + for (const message of written) { + const meta = metaOf(message); + expect(meta?.[PROTOCOL_VERSION_META_KEY]).toBeUndefined(); + } + // initialize body matches today's plain client (no probe was ever sent). + expect(written.some(m => (m as { method?: string }).method === 'server/discover')).toBe(false); + + await client.close(); + }); +}); + +describe('setVersionNegotiation()', () => { + it('configures negotiation pre-connect (equivalent to the constructor option)', async () => { + const { clientTx } = await scriptedServerSide('modern'); + const client = new Client({ name: 'setter-client', version: '1.0.0' }); + client.setVersionNegotiation({ mode: { pin: MODERN } }); + await client.connect(clientTx); + expect(client.getProtocolEra()).toBe('modern'); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + await client.close(); + }); + + it('throws after connecting to a transport', async () => { + const { clientTx } = await scriptedServerSide('legacy'); + const client = new Client({ name: 'setter-client', version: '1.0.0' }); + await client.connect(clientTx); + expect(() => client.setVersionNegotiation({ mode: 'auto' })).toThrow(/after connecting/); + await client.close(); + }); + + it('passing undefined clears a previously configured negotiation (back to the legacy default)', async () => { + const { clientTx } = await scriptedServerSide('legacy'); + const client = new Client({ name: 'setter-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + client.setVersionNegotiation(undefined); + await client.connect(clientTx); + expect(client.getProtocolEra()).toBe('legacy'); + await client.close(); + }); +}); + +describe('getProtocolEra()', () => { + it('is undefined before connect, "legacy" after a 2025 handshake, "modern" after a 2026-07-28 negotiation', async () => { + const legacy = await scriptedServerSide('legacy'); + const legacyClient = new Client({ name: 'era-client', version: '1.0.0' }); + expect(legacyClient.getProtocolEra()).toBeUndefined(); + await legacyClient.connect(legacy.clientTx); + expect(legacyClient.getProtocolEra()).toBe('legacy'); + await legacyClient.close(); + + const modern = await scriptedServerSide('modern'); + const modernClient = new Client({ name: 'era-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await modernClient.connect(modern.clientTx); + expect(modernClient.getProtocolEra()).toBe('modern'); + await modernClient.close(); + }); +}); diff --git a/packages/client/test/client/inputRequiredEngine.test.ts b/packages/client/test/client/inputRequiredEngine.test.ts new file mode 100644 index 0000000000..f688e57e1a --- /dev/null +++ b/packages/client/test/client/inputRequiredEngine.test.ts @@ -0,0 +1,374 @@ +/** + * The client-side multi-round-trip engine end to end against a scripted + * modern (2026-07-28) server: auto-fulfilment via the already-registered + * handlers, fresh request ids per leg, byte-exact requestState echo, bare + * (never wrapped) inputResponses, multi-round flows, the round cap, manual + * mode, and the synthesized handler context contract. + */ +import type { ElicitResult, JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'; +import { InMemoryTransport, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { Client } from '../../src/client/client.js'; +import type { ClientOptions } from '../../src/client/client.js'; + +const MODERN = '2026-07-28'; + +const ELICIT_ENTRY = { + method: 'elicitation/create', + params: { mode: 'form', message: 'What is your name?', requestedSchema: { type: 'object', properties: { name: { type: 'string' } } } } +}; + +interface ScriptedServer { + clientTx: InMemoryTransport; + written: JSONRPCMessage[]; + toolCalls: JSONRPCRequest[]; +} + +/** + * Scripted modern server: negotiates 2026-07-28 via server/discover and + * answers tools/call from the provided responder. + */ +async function scriptedModernServer(respondToToolCall: (request: JSONRPCRequest, call: number) => unknown): Promise { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + const toolCalls: JSONRPCRequest[] = []; + serverTx.onmessage = message => { + written.push(message); + const request = message as JSONRPCRequest; + if (request.id === undefined) return; + if (request.method === 'server/discover') { + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: {} }, + serverInfo: { name: 'scripted-mrtr-server', version: '1.0.0' } + } + }); + return; + } + if (request.method === 'tools/call') { + toolCalls.push(request); + void serverTx.send({ + jsonrpc: '2.0', + id: request.id, + result: respondToToolCall(request, toolCalls.length) + } as Parameters[0]); + } + }; + await serverTx.start(); + return { clientTx, written, toolCalls }; +} + +function makeClient(options?: ClientOptions): Client { + return new Client( + { name: 'mrtr-engine-client', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: MODERN } }, capabilities: { elicitation: { form: {} } }, ...options } + ); +} + +const COMPLETE_RESULT = { resultType: 'complete', content: [{ type: 'text', text: 'deployed' }] }; + +describe('auto-fulfilment (default on)', () => { + it('fulfils an elicitation via the registered handler and retries with a fresh id, bare responses, and a byte-exact requestState echo', async () => { + const { clientTx, toolCalls } = await scriptedModernServer((request, call) => { + if (call === 1) { + return { resultType: 'input_required', inputRequests: { github_login: ELICIT_ENTRY }, requestState: 'opaque-✓-state' }; + } + // The retry must carry the responses; echo checked below. + expect(request.params).toMatchObject({ name: 'deploy' }); + return COMPLETE_RESULT; + }); + + const client = makeClient(); + const handled: unknown[] = []; + client.setRequestHandler('elicitation/create', async request => { + handled.push(request.params); + return { action: 'accept', content: { name: 'octocat' } } satisfies ElicitResult; + }); + await client.connect(clientTx); + + const result = await client.callTool({ name: 'deploy', arguments: { env: 'prod' } }); + expect(result.content).toEqual([{ type: 'text', text: 'deployed' }]); + expect('resultType' in result).toBe(false); + + // The handler saw the embedded request params. + expect(handled).toHaveLength(1); + expect(handled[0]).toMatchObject({ mode: 'form', message: 'What is your name?' }); + + // Two independent wire legs with fresh (different) ids. + expect(toolCalls).toHaveLength(2); + expect(toolCalls[0]!.id).not.toEqual(toolCalls[1]!.id); + + // The retry carries the original params, the BARE response (no + // {method, result} wrapper), and the byte-exact requestState echo. + const retryParams = toolCalls[1]!.params as Record; + expect(retryParams.name).toBe('deploy'); + expect(retryParams.arguments).toEqual({ env: 'prod' }); + expect(retryParams.inputResponses).toEqual({ github_login: { action: 'accept', content: { name: 'octocat' } } }); + expect(retryParams.requestState).toBe('opaque-✓-state'); + + await client.close(); + }); + + it('keeps the loop going across multiple rounds and omits requestState when a round carries none', async () => { + const { clientTx, toolCalls } = await scriptedModernServer((_request, call) => { + if (call === 1) { + return { resultType: 'input_required', inputRequests: { first: ELICIT_ENTRY }, requestState: 'state-1' }; + } + if (call === 2) { + return { resultType: 'input_required', inputRequests: { second: ELICIT_ENTRY } }; + } + return COMPLETE_RESULT; + }); + + const client = makeClient(); + client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { name: 'octocat' } })); + await client.connect(clientTx); + + const result = await client.callTool({ name: 'deploy', arguments: {} }); + expect(result.content).toEqual([{ type: 'text', text: 'deployed' }]); + expect(toolCalls).toHaveLength(3); + + const secondRetry = toolCalls[2]!.params as Record; + expect(Object.keys(secondRetry.inputResponses as Record)).toEqual(['second']); + // The second input_required carried no requestState — the retry MUST NOT include one. + expect('requestState' in secondRetry).toBe(false); + + await client.close(); + }); + + it('exhausting the round cap raises the typed rounds-exceeded error carrying the last result', async () => { + const { clientTx, toolCalls } = await scriptedModernServer(() => ({ + resultType: 'input_required', + inputRequests: { again: ELICIT_ENTRY }, + requestState: 'still-going' + })); + + const client = makeClient({ inputRequired: { maxRounds: 2 } }); + client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { name: 'octocat' } })); + await client.connect(clientTx); + + const outcome = client.callTool({ name: 'deploy', arguments: {} }); + await expect(outcome).rejects.toSatisfy((error: unknown) => { + expect(error).toBeInstanceOf(SdkError); + const typed = error as SdkError; + expect(typed.code).toBe(SdkErrorCode.InputRequiredRoundsExceeded); + expect(typed.data).toMatchObject({ rounds: 2, lastResult: { requestState: 'still-going' } }); + return true; + }); + // Cap 2 ⇒ the original call plus exactly two retries reached the wire... no: + // the cap counts ROUNDS (retries); round 3 is never started, so the wire + // saw the original call + 2 retries. + expect(toolCalls).toHaveLength(3); + + await client.close(); + }); + + it('fails the call with a typed error when a required handler is not registered (reject, do not guess)', async () => { + const { clientTx } = await scriptedModernServer(() => ({ + resultType: 'input_required', + inputRequests: { sample: { method: 'sampling/createMessage', params: { messages: [], maxTokens: 5 } } } + })); + + const client = makeClient(); + await client.connect(clientTx); + + await expect(client.callTool({ name: 'deploy', arguments: {} })).rejects.toMatchObject({ + code: SdkErrorCode.CapabilityNotSupported, + data: { key: 'sample', method: 'sampling/createMessage' } + }); + + await client.close(); + }); + + it('validates a forked, tool-bearing embedded sampling response against the 2026 in-band response schema', async () => { + const SAMPLING_WITH_TOOLS_ENTRY = { + method: 'sampling/createMessage', + params: { + messages: [{ role: 'user', content: { type: 'text', text: 'What is the weather in Berlin?' } }], + maxTokens: 200, + tools: [{ name: 'get_weather', inputSchema: { type: 'object', properties: { city: { type: 'string' } } } }] + } + }; + // Forked 2026 vocabulary: array content with a tool_use block and a + // tool_result block whose structuredContent is NOT an object (the + // 2026 anchor allows any value there; the 2025 result schemas do not). + // This pins that the embedded response is validated against the era's + // in-band response schema, mirroring the request-side selection. + const TOOL_BEARING_RESPONSE = { + model: 'test-model-1', + role: 'assistant' as const, + stopReason: 'toolUse', + content: [ + { type: 'tool_use' as const, name: 'get_weather', id: 'call-1', input: { city: 'Berlin' } }, + { + type: 'tool_result' as const, + toolUseId: 'call-0', + content: [{ type: 'text' as const, text: '21°C' }], + structuredContent: 21 + } + ] + }; + const { clientTx, toolCalls } = await scriptedModernServer((_request, call) => + call === 1 ? { resultType: 'input_required', inputRequests: { weather: SAMPLING_WITH_TOOLS_ENTRY } } : COMPLETE_RESULT + ); + + const client = makeClient({ capabilities: { sampling: { tools: {} } } }); + // The non-object structuredContent is deliberately outside the 2025 + // result types (it is the 2026 fork) — hence the cast. + client.setRequestHandler('sampling/createMessage', async () => TOOL_BEARING_RESPONSE as never); + await client.connect(clientTx); + + const result = await client.callTool({ name: 'deploy', arguments: {} }); + expect(result.content).toEqual([{ type: 'text', text: 'deployed' }]); + + // The retry carries the bare tool-bearing response unchanged. + expect(toolCalls).toHaveLength(2); + const retryParams = toolCalls[1]!.params as { inputResponses?: Record }; + expect(retryParams.inputResponses?.weather).toEqual(TOOL_BEARING_RESPONSE); + + await client.close(); + }); + + it('counts the first wire leg against maxTotalTimeout (the budget bounds the whole flow)', async () => { + let now = 1_000_000; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); + try { + const { clientTx, toolCalls } = await scriptedModernServer((_request, call) => { + // The first leg alone "takes" longer than the whole-flow budget. + now += 10_000; + return call === 1 ? { resultType: 'input_required', inputRequests: { github_login: ELICIT_ENTRY } } : COMPLETE_RESULT; + }); + + const client = makeClient(); + client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { name: 'octocat' } })); + await client.connect(clientTx); + + await expect( + client.callTool({ name: 'deploy', arguments: {} }, { timeout: 60_000, maxTotalTimeout: 5_000 }) + ).rejects.toMatchObject({ code: SdkErrorCode.RequestTimeout, data: { maxTotalTimeout: 5_000 } }); + // The flow failed before any retry reached the wire. + expect(toolCalls).toHaveLength(1); + + await client.close(); + } finally { + nowSpy.mockRestore(); + } + }); + + it('fails fast with a typed error when input_required carries neither inputRequests nor requestState', async () => { + const { clientTx, toolCalls } = await scriptedModernServer(() => ({ resultType: 'input_required' })); + + const client = makeClient(); + client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { name: 'octocat' } })); + await client.connect(clientTx); + + await expect(client.callTool({ name: 'deploy', arguments: {} })).rejects.toMatchObject({ + code: SdkErrorCode.InvalidResult, + data: { method: 'tools/call', violation: 'input-required-missing-both' } + }); + // Fail fast: the original params are never resent until the cap runs out. + expect(toolCalls).toHaveLength(1); + + await client.close(); + }); + + it('fails the call with a typed error for an unknown embedded request kind', async () => { + const { clientTx } = await scriptedModernServer(() => ({ + resultType: 'input_required', + inputRequests: { weird: { method: 'tasks/create', params: {} } } + })); + + const client = makeClient(); + await client.connect(clientTx); + + await expect(client.callTool({ name: 'deploy', arguments: {} })).rejects.toMatchObject({ + code: SdkErrorCode.InvalidResult, + data: { key: 'weird', method: 'tasks/create' } + }); + + await client.close(); + }); + + it('gives the embedded handler the synthesized context: correlation-only id, chained signal, send/notify unavailable', async () => { + const { clientTx } = await scriptedModernServer((_request, call) => + call === 1 ? { resultType: 'input_required', inputRequests: { github_login: ELICIT_ENTRY } } : COMPLETE_RESULT + ); + + const client = makeClient(); + const seenCtx: unknown[] = []; + client.setRequestHandler('elicitation/create', async (_request, ctx) => { + seenCtx.push(ctx); + expect(ctx.mcpReq.id).toBe('github_login'); + expect(ctx.mcpReq.method).toBe('elicitation/create'); + expect(ctx.mcpReq.signal.aborted).toBe(false); + expect(() => ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 1, progress: 1 } })).toThrow( + /not available/ + ); + expect(() => ctx.mcpReq.send({ method: 'ping' })).toThrow(/not available/); + return { action: 'accept', content: { name: 'octocat' } }; + }); + await client.connect(clientTx); + + await client.callTool({ name: 'deploy', arguments: {} }); + expect(seenCtx).toHaveLength(1); + + await client.close(); + }); +}); + +describe('manual mode', () => { + it('autoFulfill: false surfaces input_required as a typed error (no retries hit the wire)', async () => { + const { clientTx, toolCalls } = await scriptedModernServer(() => ({ + resultType: 'input_required', + inputRequests: { github_login: ELICIT_ENTRY } + })); + + const client = makeClient({ inputRequired: { autoFulfill: false } }); + client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { name: 'octocat' } })); + await client.connect(clientTx); + + await expect(client.callTool({ name: 'deploy', arguments: {} })).rejects.toMatchObject({ + code: SdkErrorCode.UnsupportedResultType, + data: { resultType: 'input_required', method: 'tools/call' } + }); + expect(toolCalls).toHaveLength(1); + + await client.close(); + }); + + it('allowInputRequired: true hands the input-required value back to the caller, who can retry manually', async () => { + const { clientTx, toolCalls } = await scriptedModernServer((_request, call) => + call === 1 + ? { resultType: 'input_required', inputRequests: { github_login: ELICIT_ENTRY }, requestState: 'manual-state' } + : COMPLETE_RESULT + ); + + const client = makeClient({ inputRequired: { autoFulfill: false } }); + await client.connect(clientTx); + + const first = (await client.callTool({ name: 'deploy', arguments: {} }, { allowInputRequired: true })) as unknown as Record< + string, + unknown + >; + expect(first.resultType).toBe('input_required'); + expect(first.requestState).toBe('manual-state'); + + // The caller drives the retry itself: same params + responses + echo. + const second = await client.callTool({ + name: 'deploy', + arguments: {}, + inputResponses: { github_login: { action: 'accept', content: { name: 'octocat' } } }, + requestState: first.requestState as string + } as Parameters[0]); + expect(second.content).toEqual([{ type: 'text', text: 'deployed' }]); + expect(toolCalls).toHaveLength(2); + expect(toolCalls[0]!.id).not.toEqual(toolCalls[1]!.id); + + await client.close(); + }); +}); diff --git a/packages/client/test/client/jsonSchemaValidatorOverride.test.ts b/packages/client/test/client/jsonSchemaValidatorOverride.test.ts index 2e38f618c5..dc55776eda 100644 --- a/packages/client/test/client/jsonSchemaValidatorOverride.test.ts +++ b/packages/client/test/client/jsonSchemaValidatorOverride.test.ts @@ -48,6 +48,12 @@ async function connectInitializedClient(client: Client) { ] } } satisfies JSONRPCMessage); + } else if ('method' in message && 'id' in message && message.method === 'tools/call') { + await serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + result: { content: [], structuredContent: { count: 42 } } + } satisfies JSONRPCMessage); } }; @@ -56,7 +62,7 @@ async function connectInitializedClient(client: Client) { } describe('client JSON Schema validator overrides', () => { - test('Client constructor uses a custom validator for tool output schema caching', async () => { + test('Client uses the custom validator for tool output validation (derived from the cached tools/list entry)', async () => { const validator = new RecordingValidator(); const client = new Client( { name: 'test-client', version: '1.0.0' }, @@ -67,6 +73,8 @@ describe('client JSON Schema validator overrides', () => { ); const { clientTransport, serverTransport } = await connectInitializedClient(client); + // The validator index reads the cached `tools/list` entry; populate it + // via the public auto-aggregating listTools(). await expect(client.listTools()).resolves.toMatchObject({ tools: [ { @@ -80,6 +88,14 @@ describe('client JSON Schema validator overrides', () => { ] }); + // Derived-view behavior: the validator index re-derives lazily on the + // first callTool against the cached entry's stamp — populating the + // cache alone does not compile. + expect(validator.schemas).toEqual([]); + + await expect(client.callTool({ name: 'structured-tool' })).resolves.toMatchObject({ + structuredContent: { count: 42 } + }); expect(validator.schemas).toEqual([ { type: 'object', @@ -87,12 +103,100 @@ describe('client JSON Schema validator overrides', () => { required: ['count'] } ]); + expect(validator.values).toEqual([{ count: 42 }]); + + // Same backing entry stamp → memoized; a second callTool does not recompile. + await client.callTool({ name: 'structured-tool' }); + expect(validator.schemas).toHaveLength(1); await client.close(); await clientTransport.close(); await serverTransport.close(); }); + describe('outputSchema compile-error lifecycle (substrate-held; no parallel map)', () => { + // SEP-2106 §invalid-outputSchema: a tool whose outputSchema fails to compile is + // surfaced as a typed InvalidParams BEFORE the request is sent. The compile error is + // held on the response-cache substrate's stamp-keyed `name → validator` index, so it + // inherits that substrate's invalidation lifecycle — a refetched `tools/list` re-derives + // it from scratch (no stale-entry bug when the server fixes the tool by removing the + // schema). The caller-supplied `toolDefinition` path is compiled in isolation and never + // touches the cache, so a one-off bad definition cannot poison the listed tool. + async function connectMutableToolsClient(getTools: () => unknown[]) { + const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + serverTransport.onmessage = async message => { + if (!('method' in message) || !('id' in message)) return; + if (message.method === 'initialize') { + await serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: 'test-server', version: '1.0.0' } + } + }); + } else if (message.method === 'tools/list') { + await serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + result: { tools: getTools() } + } satisfies JSONRPCMessage); + } else if (message.method === 'tools/call') { + await serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + result: { content: [{ type: 'text', text: 'ok' }], structuredContent: { count: 1 } } + } satisfies JSONRPCMessage); + } + }; + await Promise.all([client.connect(clientTransport), serverTransport.start()]); + return { client, close: () => Promise.all([client.close(), clientTransport.close(), serverTransport.close()]) }; + } + + // An external `$ref` throws at compile time inside Ajv (MissingRefError — no fetch is + // attempted) and is captured per-tool by `_compileOutputValidator`. + const BAD_SCHEMA = { type: 'object', $ref: 'https://example.invalid/schema.json' } as const; + const GOOD_SCHEMA = { type: 'object', properties: { count: { type: 'number' } } } as const; + + test('re-advertising a tool WITHOUT the bad outputSchema clears the captured failure', async () => { + let tools: unknown[] = [{ name: 't', inputSchema: { type: 'object' }, outputSchema: BAD_SCHEMA }]; + const { client, close } = await connectMutableToolsClient(() => tools); + + await client.listTools(); + await expect(client.callTool({ name: 't' })).rejects.toThrow(/invalid outputSchema/); + + // Server fixes the tool by removing outputSchema entirely; refetched `tools/list` + // re-derives the index from scratch — no stale compile-error entry survives. + tools = [{ name: 't', inputSchema: { type: 'object' } }]; + await client.listTools(); + await expect(client.callTool({ name: 't' })).resolves.toMatchObject({ + content: [{ type: 'text', text: 'ok' }] + }); + + await close(); + }); + + test('a one-off `toolDefinition` with a bad outputSchema does not poison the listed tool', async () => { + const tools: unknown[] = [{ name: 't', inputSchema: { type: 'object' }, outputSchema: GOOD_SCHEMA }]; + const { client, close } = await connectMutableToolsClient(() => tools); + + await client.listTools(); + await expect( + client.callTool({ name: 't' }, { toolDefinition: { name: 't', inputSchema: { type: 'object' }, outputSchema: BAD_SCHEMA } }) + ).rejects.toThrow(/invalid outputSchema/); + + // Subsequent plain callTool of the same name (against the cached, valid listed + // schema) succeeds — the one-off definition never entered the cache. + await expect(client.callTool({ name: 't' })).resolves.toMatchObject({ + structuredContent: { count: 1 } + }); + + await close(); + }); + }); + test('fromJsonSchema uses an explicitly supplied custom validator', async () => { const validator = new RecordingValidator(); const schema: JsonSchemaType = { 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/listen.test.ts b/packages/client/test/client/listen.test.ts new file mode 100644 index 0000000000..86e921e960 --- /dev/null +++ b/packages/client/test/client/listen.test.ts @@ -0,0 +1,984 @@ +/** + * `Client.listen()` — the `subscriptions/listen` driver (protocol revision + * 2026-07-28). Covers ack-resolved-promise, change-notification dispatch to + * existing setNotificationHandler registrations, the F-12 legacy-era steer, + * transport-agnostic close (always sends notifications/cancelled), inbound + * server-side cancel, and ClientOptions.listChanged auto-open on a modern + * connection. + */ +import type { JSONRPCMessage, JSONRPCNotification } from '@modelcontextprotocol/core'; +import { + InMemoryTransport, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY, + SdkError, + SdkErrorCode, + SUBSCRIPTION_ID_META_KEY +} from '@modelcontextprotocol/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Client } from '../../src/client/client.js'; + +const MODERN = '2026-07-28'; +const flush = () => new Promise(r => setTimeout(r, 10)); + +async function scriptedModern(onListen?: (id: number | string, filter: unknown, send: (m: JSONRPCMessage) => void) => void) { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + serverTx.onmessage = message => { + written.push(message); + const req = message as { id?: number | string; method?: string; params?: { notifications?: unknown } }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: { listChanged: true }, prompts: { listChanged: true } }, + serverInfo: { name: 'scripted', version: '1' } + } + }); + } + if (req.method === 'subscriptions/listen' && req.id !== undefined) { + const filter = req.params?.notifications ?? {}; + const ack: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: req.id }, notifications: filter } + }; + void serverTx.send(ack); + onListen?.(req.id, filter, m => void serverTx.send(m)); + } + }; + await serverTx.start(); + return { clientTx, serverTx, written }; +} + +/** + * Like `scriptedModern` but does NOT auto-ack `subscriptions/listen`: the + * test drives ack / cancel / transport-close itself. + */ +async function scriptedModernNoAck() { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + serverTx.onmessage = message => { + written.push(message); + const req = message as { id?: number | string; method?: string }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: { listChanged: true }, prompts: { listChanged: true } }, + serverInfo: { name: 'scripted', version: '1' } + } + }); + } + }; + await serverTx.start(); + return { clientTx, serverTx, written }; +} + +describe('Client.listen()', () => { + it('throws a typed steer on a legacy-era connection (no wire write)', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + serverTx.onmessage = m => { + written.push(m); + const req = m as { id?: number | string; method?: string }; + if (req.method === 'initialize' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, serverInfo: { name: 's', version: '1' } } + }); + } + }; + await serverTx.start(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'legacy' } }); + await client.connect(clientTx); + written.length = 0; + + const error = await client.listen({ toolsListChanged: true }).catch(e => e as SdkError); + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + expect((error as SdkError).message).toContain('resources/subscribe'); + expect((error as SdkError).message).toContain('listChanged'); + // The steer fires before any wire write. + expect(written.some(m => (m as { method?: string }).method === 'subscriptions/listen')).toBe(false); + await client.close(); + }); + + it('resolves on ack with the honored filter; change notifications reach setNotificationHandler', async () => { + let send!: (m: JSONRPCMessage) => void; + const { clientTx } = await scriptedModern((_id, _f, s) => { + send = s; + }); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + const seen: string[] = []; + client.setNotificationHandler('notifications/tools/list_changed', () => { + seen.push('tools'); + }); + await client.connect(clientTx); + + const sub = await client.listen({ toolsListChanged: true }); + expect(sub.honoredFilter).toEqual({ toolsListChanged: true }); + + send({ + jsonrpc: '2.0', + method: 'notifications/tools/list_changed', + params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: 0 } } + }); + await flush(); + expect(seen).toEqual(['tools']); + await sub.close(); + await client.close(); + }); + + it('close() sends notifications/cancelled referencing the listen id on any transport', async () => { + // Plain InMemoryTransport (neither child-process nor SSE-stream + // semantics): close() must NOT depend on transport-kind detection — + // it always sends notifications/cancelled, so a spec-compliant server + // on InMemory / SSE / a custom transport tears the subscription down. + const { clientTx, written } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + const listenId = (written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { id: number | string }).id; + written.length = 0; + await sub.close(); + expect(written).toHaveLength(1); + const cancel = written[0] as unknown as { method: string; params: { requestId: unknown; _meta?: Record } }; + expect(cancel.method).toBe('notifications/cancelled'); + expect(cancel.params.requestId).toBe(listenId); + // The listen-path cancel carries the same modern auto-envelope as + // every other outbound (request()'s cancel, Protocol.notification()). + expect(cancel.params._meta?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN); + // Idempotent. + await sub.close(); + expect(written).toHaveLength(1); + await client.close(); + }); + + it("inbound notifications/cancelled post-ack: closed resolves 'remote'; subscription torn down; handlers stop firing", async () => { + let listenId!: number | string; + let send!: (m: JSONRPCMessage) => void; + const { clientTx } = await scriptedModern((id, _f, s) => { + listenId = id; + send = s; + }); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + const seen: string[] = []; + client.setNotificationHandler('notifications/tools/list_changed', () => { + seen.push('tools'); + }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification); + // The spec-defined remote termination signal is now observable on the + // subscription handle; settle() is the funnel and resolves it once. + await expect(sub.closed).resolves.toBe('remote'); + // Per-listen state is gone; the request signal was aborted (so an HTTP + // SSE reader would have stopped). + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + // After a server-side close, the server stops delivering on this stream + // — a notification carrying this subscription id is no longer routed + // through any per-listen entry (the entry is gone). The handler is the + // shared setNotificationHandler registration; assert no later + // dispatch from THIS subscription's stream by asserting no entry exists + // to demux it. + expect((client as unknown as { _listenState: Map })._listenState.has(listenId)).toBe(false); + expect(seen).toEqual([]); + // close() after server-cancel is idempotent and does NOT change the + // already-resolved cause. + await sub.close(); + await expect(sub.closed).resolves.toBe('remote'); + await client.close(); + }); + + it("close() resolves closed with 'local' exactly once", async () => { + const { clientTx } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + await sub.close(); + await expect(sub.closed).resolves.toBe('local'); + // A second close() and a later remote signal cannot change it. + await sub.close(); + await expect(sub.closed).resolves.toBe('local'); + await client.close(); + }); + + it('closed resolves exactly once even when multiple termination signals arrive', async () => { + let listenId!: number | string; + let send!: (m: JSONRPCMessage) => void; + const { clientTx, serverTx } = await scriptedModern((id, _f, s) => { + listenId = id; + send = s; + }); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + const resolutions: string[] = []; + void sub.closed.then(cause => resolutions.push(cause)); + // Three signals in quick succession: server-cancel, a duplicate + // server-cancel, then transport close. settle()'s `closed` guard + // means only the first transitions; `closed` resolves once. + send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification); + send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification); + await serverTx.close(); + await flush(); + expect(resolutions).toEqual(['remote']); + // sub.close() after the fact is still idempotent and cannot flip it. + await sub.close(); + await expect(sub.closed).resolves.toBe('remote'); + }); + + it('rejects with the typed pre-ack error when the server answers -32603', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = m => { + const req = m as { id?: number | string; method?: string }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: {}, + serverInfo: { name: 's', version: '1' } + } + }); + } + if (req.method === 'subscriptions/listen' && req.id !== undefined) { + void serverTx.send({ jsonrpc: '2.0', id: req.id, error: { code: -32_603, message: 'Subscription limit reached' } }); + } + }; + await serverTx.start(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error); + expect(error).toBeInstanceOf(Error); + expect((error as { code?: number }).code).toBe(-32_603); + await client.close(); + }); + + it('server cancels BEFORE the ack: listen() rejects immediately, no 60s hang', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = m => { + const req = m as { id?: number | string; method?: string }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: {}, + serverInfo: { name: 's', version: '1' } + } + }); + } + if (req.method === 'subscriptions/listen' && req.id !== undefined) { + // Server cancels the listen id BEFORE sending the ack. + void serverTx.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: req.id } }); + } + }; + await serverTx.start(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const t0 = Date.now(); + const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('server cancelled the subscription'); + // Rejected promptly (well under the 60s ack timeout). + expect(Date.now() - t0).toBeLessThan(1000); + // No leaked per-listen state for the listen id. + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + await client.close(); + }); + + it('an ack arriving AFTER the subscription was server-cancelled is a no-op', async () => { + let listenId!: number | string; + let send!: (m: JSONRPCMessage) => void; + const { clientTx } = await scriptedModern((id, _f, s) => { + listenId = id; + send = s; + }); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + // Server tears the open subscription down. + send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification); + await flush(); + // A late duplicate ack must not throw or resurrect state. + send({ + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: listenId }, notifications: {} } + }); + await flush(); + await sub.close(); + await client.close(); + }); + + it('a synchronously-delivered server-cancel during send does not leak a _listenState entry', async () => { + // In-process delivery: the server's notifications/cancelled arrives + // inside `transport.send()` (before the `await opening`). settle() + // must still drop the `_listenState` entry registered before send. + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = m => { + const req = m as { id?: number | string; method?: string }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: {}, + serverInfo: { name: 's', version: '1' } + } + }); + } + if (req.method === 'subscriptions/listen' && req.id !== undefined) { + void serverTx.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: req.id } }); + } + }; + await serverTx.start(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const listenState = (client as unknown as { _listenState: Map })._listenState; + const before = listenState.size; + const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error); + expect((error as Error).message).toContain('server cancelled the subscription'); + // No leaked _listenState entry for the listen id. + expect(listenState.size).toBe(before); + await client.close(); + }); + + it('a synchronous transport.send throw does not leak a _listenState entry', async () => { + const { clientTx } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const realSend = clientTx.send.bind(clientTx); + clientTx.send = () => { + throw new Error('send blew up'); + }; + const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error); + expect((error as Error).message).toContain('send blew up'); + // settle() in the catch path dropped the _listenState entry that was + // registered before send threw; listen() never registers in + // Protocol's `_responseHandlers` so there is nothing to leak there. + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + expect((client as unknown as { _responseHandlers: Map })._responseHandlers.size).toBe(0); + clientTx.send = realSend; + await client.close(); + }); + + it('options.signal already aborted: listen() rejects with SdkError(RequestTimeout) before any setup (parity with request())', async () => { + const { clientTx, written } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + written.length = 0; + const ac = new AbortController(); + ac.abort('user cancelled'); + const error = await client.listen({ toolsListChanged: true }, { signal: ac.signal }).catch(e => e as SdkError); + // Same wrap as `Protocol.request()` / `_serveFromCache`: a non-SdkError + // reason is wrapped as RequestTimeout; the reason text is preserved. + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.RequestTimeout); + expect((error as SdkError).message).toContain('user cancelled'); + // No subscriptions/listen reached the wire; no listen state registered. + await flush(); + expect(written.find(m => (m as { method?: string }).method === 'subscriptions/listen')).toBeUndefined(); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + // An SdkError reason is preserved verbatim (not double-wrapped). + const ac2 = new AbortController(); + const own = new SdkError(SdkErrorCode.NotConnected, 'upstream'); + ac2.abort(own); + const error2 = await client.listen({ toolsListChanged: true }, { signal: ac2.signal }).catch(e => e as SdkError); + expect(error2).toBe(own); + await client.close(); + }); + + it('options.signal aborted while opening: listen() rejects fast with the signal reason', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + serverTx.onmessage = m => { + written.push(m); + const req = m as { id?: number | string; method?: string }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: {}, + serverInfo: { name: 's', version: '1' } + } + }); + } + // No ack for subscriptions/listen — stays in `opening`. + }; + await serverTx.start(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const ac = new AbortController(); + const t0 = Date.now(); + const pending = client.listen({ toolsListChanged: true }, { signal: ac.signal }); + ac.abort(new Error('caller-abort')); + const error = await pending.catch(e => e as Error); + expect((error as Error).message).toBe('caller-abort'); + expect(Date.now() - t0).toBeLessThan(1000); + // wireTeardown sent notifications/cancelled referencing the listen id. + await flush(); + const listenId = (written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { id: number | string }).id; + const cancelled = written.find(m => (m as { method?: string }).method === 'notifications/cancelled') as + | { params: { requestId: unknown } } + | undefined; + expect(cancelled?.params.requestId).toBe(listenId); + // No leaked state. + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + await client.close(); + }); + + it('options.signal aborted while open: closes the subscription (notifications/cancelled sent)', async () => { + const { clientTx, written } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const ac = new AbortController(); + const sub = await client.listen({ toolsListChanged: true }, { signal: ac.signal }); + const listenId = (written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { id: number | string }).id; + written.length = 0; + ac.abort(); + await flush(); + expect(written).toHaveLength(1); + expect((written[0] as JSONRPCNotification).method).toBe('notifications/cancelled'); + expect((written[0] as unknown as { params: { requestId: unknown } }).params.requestId).toBe(listenId); + // Caller-signal abort is consumer-initiated → 'local'. + await expect(sub.closed).resolves.toBe('local'); + // close() after signal-abort is idempotent. + await sub.close(); + expect(written).toHaveLength(1); + await client.close(); + }); + + it('rejects with NotConnected (as a rejected promise, no setup) when no transport is connected', async () => { + const { clientTx } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + await client.close(); + // listen() is async, so a pre-send guard throw is delivered as the + // returned promise's rejection (no ack timer started, no park state). + const pending = client.listen({ toolsListChanged: true }); + const error = await pending.catch(e => e as SdkError); + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.NotConnected); + }); + + it('ClientOptions.listChanged auto-opens a listen stream on a modern connection (filter = configured ∩ server-advertised)', async () => { + const filters: unknown[] = []; + const { clientTx } = await scriptedModern((_id, filter) => filters.push(filter)); + const onChanged = () => {}; + const client = new Client( + { name: 'c', version: '1' }, + { versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged }, prompts: { onChanged } } } + ); + await client.connect(clientTx); + expect(filters).toEqual([{ toolsListChanged: true, promptsListChanged: true }]); + expect(client.autoOpenedSubscription).toBeDefined(); + expect(client.autoOpenedSubscription!.honoredFilter).toEqual({ toolsListChanged: true, promptsListChanged: true }); + await client.autoOpenedSubscription!.close(); + await client.close(); + }); + + it('autoOpenedSubscription is cleared on close() and on a fresh reconnect', async () => { + const onChanged = () => {}; + const client = new Client( + { name: 'c', version: '1' }, + { versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged } } } + ); + const { clientTx } = await scriptedModern(); + await client.connect(clientTx); + expect(client.autoOpenedSubscription).toBeDefined(); + await client.close(); + // close() clears every per-connection field. + expect(client.autoOpenedSubscription).toBeUndefined(); + expect(client.getServerCapabilities()).toBeUndefined(); + expect(client.getNegotiatedProtocolVersion()).toBeUndefined(); + }); + + it('auto-open filter is configured ∩ server-advertised; empty intersection skips auto-open', async () => { + const filters: unknown[] = []; + // scriptedModern advertises tools.listChanged + prompts.listChanged but NOT resources. + const { clientTx } = await scriptedModern((_id, filter) => filters.push(filter)); + const onChanged = () => {}; + const client = new Client( + { name: 'c', version: '1' }, + // Configures tools + resources; server advertises tools + prompts. + { versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged }, resources: { onChanged } } } + ); + await client.connect(clientTx); + // Intersection = tools only. + expect(filters).toEqual([{ toolsListChanged: true }]); + expect(client.autoOpenedSubscription?.honoredFilter).toEqual({ toolsListChanged: true }); + await client.close(); + + // Empty intersection: configures resources only; server advertises tools+prompts. + const filters2: unknown[] = []; + const { clientTx: clientTx2 } = await scriptedModern((_id, filter) => filters2.push(filter)); + const client2 = new Client( + { name: 'c', version: '1' }, + { versionNegotiation: { mode: 'auto' }, listChanged: { resources: { onChanged } } } + ); + await client2.connect(clientTx2); + expect(filters2).toEqual([]); + expect(client2.autoOpenedSubscription).toBeUndefined(); + await client2.close(); + }); + + it('a failed auto-open surfaces via onerror and does NOT fail connect', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = m => { + const req = m as { id?: number | string; method?: string }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 's', version: '1' } + } + }); + } + if (req.method === 'subscriptions/listen' && req.id !== undefined) { + // Server refuses listen (capacity guard / not supported). + void serverTx.send({ jsonrpc: '2.0', id: req.id, error: { code: -32_603, message: 'Subscription limit reached' } }); + } + }; + await serverTx.start(); + const onChanged = () => {}; + const client = new Client( + { name: 'c', version: '1' }, + { versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged } } } + ); + const errors: Error[] = []; + client.onerror = e => errors.push(e); + // connect MUST resolve: the modern connection is usable without listen. + await client.connect(clientTx); + expect(client.autoOpenedSubscription).toBeUndefined(); + expect(errors).toHaveLength(1); + expect((errors[0] as { code?: number }).code).toBe(-32_603); + await client.close(); + }); + + it('a misconfigured listChanged handler surfaces via onerror and SKIPS auto-open (no wire write)', async () => { + // Regression: when handler registration threw (the soft-fail catch), + // the auto-open filter was still built from the same `effective`, + // opening a listen stream for types whose handler never registered — + // delivered notifications dropped on the floor while consuming a + // server slot. Now a registration failure skips auto-open entirely. + const { clientTx, written } = await scriptedModernNoAck(); + const onChanged = () => {}; + const client = new Client( + { name: 'c', version: '1' }, + { versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged, debounceMs: -1 } } } + ); + const errors: Error[] = []; + client.onerror = e => errors.push(e); + // connect MUST resolve: the modern connection is usable without listen. + await client.connect(clientTx); + expect(errors).toHaveLength(1); + expect(errors[0]!.message).toContain('Invalid tools listChanged options'); + // Auto-open SKIPPED: no listen request hit the wire, no subscription. + expect(client.autoOpenedSubscription).toBeUndefined(); + expect(written.some(m => (m as { method?: string }).method === 'subscriptions/listen')).toBe(false); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + await client.close(); + }); + + it('connect-scoped signal does NOT bind to the auto-opened subscription lifetime', async () => { + // Regression: forwarding connect()'s full RequestOptions into the + // auto-open listen() call meant a connect-scoped signal — typically + // `AbortSignal.timeout(30_000)` for the handshake — was bound to the + // SUBSCRIPTION lifetime. When it fired after connect resolved, the + // auto-opened stream was silently torn down. + const { clientTx, written } = await scriptedModern(); + const onChanged = () => {}; + const client = new Client( + { name: 'c', version: '1' }, + { versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged } } } + ); + const errors: Error[] = []; + client.onerror = e => errors.push(e); + const connectScoped = new AbortController(); + await client.connect(clientTx, { signal: connectScoped.signal }); + expect(client.autoOpenedSubscription).toBeDefined(); + written.length = 0; + + // The connect-scoped signal fires AFTER connect resolved (as a + // handshake `AbortSignal.timeout` would). + connectScoped.abort(); + await flush(); + + // The auto-opened subscription is still live: no wire teardown + // (`notifications/cancelled`) was sent, and the per-listen state + // entry is still registered. + expect(written.some(m => (m as JSONRPCNotification).method === 'notifications/cancelled')).toBe(false); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(1); + expect(errors).toHaveLength(0); + await client.close(); + }); + + it('connect-scoped signal aborted DURING the auto-open ack wait: connect rejects fast (no 60s hang)', async () => { + // Regression: forwarding only {timeout} into the auto-open listen() + // meant connect()'s signal could not cancel the in-connect ack wait — + // an aborted connect blocked here for the full ack timeout. + const { clientTx } = await scriptedModernNoAck(); + const closeSpy = vi.spyOn(clientTx, 'close'); + const onChanged = () => {}; + const client = new Client( + { name: 'c', version: '1' }, + { versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged } } } + ); + const connectScoped = new AbortController(); + const t0 = Date.now(); + const pending = client.connect(clientTx, { signal: connectScoped.signal }); + // discover resolves; connect is now awaiting the auto-open ack. + await flush(); + connectScoped.abort(new Error('connect-abort')); + const error = await pending.catch(e => e as Error); + expect(error).toBeInstanceOf(Error); + expect(Date.now() - t0).toBeLessThan(1000); + // No leaked per-listen state on the aborted connect. + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + // A connect() rejection MUST NOT leave a half-open connection: the + // transport was closed before rethrowing (b142b80ea regression assertion). + await flush(); + expect(closeSpy).toHaveBeenCalled(); + expect(client.transport).toBeUndefined(); + await client.close(); + }); + + it('server answers listen with a JSON-RPC RESULT during opening: rejects ConnectionClosed (graceful pre-ack close, not 60s)', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = m => { + const req = m as { id?: number | string; method?: string }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 's', version: '1' } + } + }); + } + if (req.method === 'subscriptions/listen' && req.id !== undefined) { + // Server is shutting down: emits the SubscriptionsListenResult + // before ever sending the ack. The client treats receipt of + // any result for the listen id as the graceful-close signal. + void serverTx.send({ jsonrpc: '2.0', id: req.id, result: {} }); + } + }; + await serverTx.start(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const t0 = Date.now(); + const error = await client.listen({ toolsListChanged: true }).catch(e => e as SdkError); + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.ConnectionClosed); + expect((error as SdkError).message).toContain('closed the subscription gracefully before acknowledging'); + expect(Date.now() - t0).toBeLessThan(1000); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + await client.close(); + }); + + it("inbound SubscriptionsListenResult post-ack: closed resolves 'graceful'; subscription torn down", async () => { + let listenId!: number | string; + let send!: (m: JSONRPCMessage) => void; + const { clientTx } = await scriptedModern((id, _f, s) => { + listenId = id; + send = s; + }); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + // The spec's graceful-close signal: the server emits the empty + // subscriptions/listen response, then closes the stream. + send({ + jsonrpc: '2.0', + id: listenId, + result: { resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: listenId } } + } as JSONRPCMessage); + await expect(sub.closed).resolves.toBe('graceful'); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + await client.close(); + }); + + it('transport closes BEFORE the ack: listen() rejects fast', async () => { + const { clientTx, serverTx } = await scriptedModernNoAck(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const t0 = Date.now(); + const pending = client.listen({ toolsListChanged: true }); + await flush(); + // Server-side transport closes before ever acking → Client's + // `_onclose` override settles every per-listen state machine. + await serverTx.close(); + const error = await pending.catch(e => e as Error); + expect(error).toBeInstanceOf(Error); + expect(Date.now() - t0).toBeLessThan(1000); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + expect((client as unknown as { _responseHandlers: Map })._responseHandlers.size).toBe(0); + }); + + it("transport closes WHILE the subscription is open: closed resolves 'remote'; close() is a no-op", async () => { + const { clientTx, serverTx, written } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(1); + await serverTx.close(); + await expect(sub.closed).resolves.toBe('remote'); + // Transport-close settled the per-listen machine; nothing leaks. + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + // sub.close() after transport-close is a no-op (state already 'closed'): + // no notifications/cancelled lands on a future connection. + written.length = 0; + await sub.close(); + expect(written.some(m => (m as { method?: string }).method === 'notifications/cancelled')).toBe(false); + }); + + it('concurrent listens are independent (each ack resolves its own promise; closing one leaves the other open)', async () => { + const ids: (number | string)[] = []; + const { clientTx, written } = await scriptedModern(id => ids.push(id)); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const [a, b] = await Promise.all([client.listen({ toolsListChanged: true }), client.listen({ promptsListChanged: true })]); + expect(a.honoredFilter).toEqual({ toolsListChanged: true }); + expect(b.honoredFilter).toEqual({ promptsListChanged: true }); + expect(ids).toHaveLength(2); + expect(ids[0]).not.toBe(ids[1]); + const listenState = (client as unknown as { _listenState: Map })._listenState; + expect(listenState.size).toBe(2); + written.length = 0; + await a.close(); + // Only `a`'s id is cancelled; `b` stays open. + expect(written).toHaveLength(1); + expect((written[0] as JSONRPCNotification).method).toBe('notifications/cancelled'); + expect((written[0] as unknown as { params: { requestId: unknown } }).params.requestId).toBe(ids[0]); + expect(listenState.size).toBe(1); + await b.close(); + expect(listenState.size).toBe(0); + await client.close(); + }); + + it('after close(): nothing further dispatched into the per-listen machine; late ack passes through unconsumed', async () => { + let listenId!: number | string; + let send!: (m: JSONRPCMessage) => void; + const { clientTx } = await scriptedModern((id, _f, s) => { + listenId = id; + send = s; + }); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + await sub.close(); + // The per-listen entry is gone; a late server-side ack and a late + // server-side cancel for this id are NOT consumed by the + // `_onnotification` override (no entry matches) and reach the + // fallback handler. + const fallback: string[] = []; + client.fallbackNotificationHandler = async n => { + fallback.push(n.method); + }; + send({ + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: listenId }, notifications: {} } + }); + send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification); + await flush(); + expect(fallback).toContain('notifications/subscriptions/acknowledged'); + // The state machine stayed closed throughout (no leak, no resurrection). + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + await client.close(); + }); + + it('an unmatched ack passes through to fallbackNotificationHandler (not silently swallowed)', async () => { + const { clientTx, serverTx } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + const fallback: string[] = []; + client.fallbackNotificationHandler = async n => { + fallback.push(n.method); + }; + await client.connect(clientTx); + // One listen is active; a stray ack referencing a FOREIGN id must + // reach the fallback handler instead of being silently swallowed. + const sub = await client.listen({ toolsListChanged: true }); + await serverTx.send({ + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: 'foreign-id' }, notifications: {} } + }); + await flush(); + expect(fallback).toEqual(['notifications/subscriptions/acknowledged']); + await sub.close(); + await client.close(); + }); + + it('a fresh connect without an intervening close settles in-flight listen() from the prior connection', async () => { + // Edge: prior transport never fires onclose; consumer calls connect() + // again. The in-flight listen() promise from the old connection must + // reject with a clear "client reconnected/closed" error rather than + // hang on the (now-discarded) ack timer. + const { clientTx } = await scriptedModernNoAck(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const pending = client.listen({ toolsListChanged: true }); + await flush(); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(1); + // Fresh connect on a new transport — _resetConnectionState runs. + const { clientTx: clientTx2 } = await scriptedModern(); + await client.connect(clientTx2); + const error = await pending.catch(e => e as Error); + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.ConnectionClosed); + expect((error as SdkError).message).toContain('reconnected or closed'); + // No leaked per-listen state from the old connection. + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + await client.close(); + }); + + it("the listen request id is a STRING on the wire ('listen:N'); cancel echoes it verbatim", async () => { + const { clientTx, written } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + const wireListen = written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { + id: unknown; + params: { _meta?: Record }; + }; + // String id from a Client-owned counter — JSON-RPC valid; spec + // subscriptionId is the request id verbatim; zero collision with + // Protocol's numeric counter. + expect(typeof wireListen.id).toBe('string'); + expect(wireListen.id).toMatch(/^listen:\d+$/); + // The auto-envelope is on the wire too. + expect(wireListen.params._meta?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN); + written.length = 0; + await sub.close(); + const cancel = written[0] as unknown as { method: string; params: { requestId: unknown } }; + expect(cancel.params.requestId).toBe(wireListen.id); + await client.close(); + }); + + it("transport-level per-request stream end (onRequestStreamEnd) → closed resolves 'remote'", async () => { + // Mock a transport that captures the per-request `onRequestStreamEnd` + // callback and fires it after the ack — simulating a Streamable HTTP + // server closing the listen request's SSE stream. + const { clientTx, serverTx } = await scriptedModern(); + let onStreamEnd: (() => void) | undefined; + const realSend = clientTx.send.bind(clientTx); + clientTx.send = (m, opts) => { + if ((m as { method?: string }).method === 'subscriptions/listen') { + onStreamEnd = (opts as { onRequestStreamEnd?: () => void } | undefined)?.onRequestStreamEnd; + } + return realSend(m, opts); + }; + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + expect(onStreamEnd).toBeDefined(); + // Transport reports the per-request stream ended (server closed the + // SSE response, network dropped it, reconnection exhausted). + onStreamEnd!(); + await expect(sub.closed).resolves.toBe('remote'); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + // close() after stream-end is a no-op (state already 'closed'). + await sub.close(); + await serverTx.close(); + }); + + it('close() resets per-connection state even when transport.close() rejects', async () => { + const { clientTx } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + clientTx.close = () => Promise.reject(new Error('close blew up')); + await expect(client.close()).rejects.toThrow('close blew up'); + // Per-connection state was cleared regardless. + expect(client.getNegotiatedProtocolVersion()).toBeUndefined(); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + }); +}); + +describe('_resetConnectionState() clears connection-scoped debounce timers (fake timers)', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('a debounced listChanged callback armed on a closed connection never fires', async () => { + const { clientTx, serverTx } = await scriptedModernNoAck(); + const calls: unknown[] = []; + const client = new Client( + { name: 'c', version: '1' }, + { + versionNegotiation: { mode: 'auto' }, + listChanged: { tools: { onChanged: (e, items) => calls.push({ e, items }), autoRefresh: false, debounceMs: 100 } } + } + ); + const connecting = client.connect(clientTx); + await vi.runAllTimersAsync(); + await connecting; + // Arm the debounce timer for `tools` on the current connection. + await serverTx.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' }); + await vi.advanceTimersByTimeAsync(0); + expect((client as unknown as { _listChangedDebounceTimers: Map })._listChangedDebounceTimers.size).toBe(1); + // close() → _resetConnectionState() must clear the armed timer so the + // callback for the dead connection never fires. + await client.close(); + expect((client as unknown as { _listChangedDebounceTimers: Map })._listChangedDebounceTimers.size).toBe(0); + await vi.advanceTimersByTimeAsync(200); + expect(calls).toEqual([]); + }); +}); + +describe('Client.listen() — ack timeout (fake timers)', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('ack timer firing rejects with RequestTimeout and tears the wire down', async () => { + const { clientTx, written } = await scriptedModernNoAck(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + const connecting = client.connect(clientTx); + await vi.runAllTimersAsync(); + await connecting; + const pending = client.listen({ toolsListChanged: true }, { timeout: 1000 }); + // Capture rejection to avoid an unhandled-rejection on the timer tick. + const settled = pending.catch(e => e as SdkError); + await vi.advanceTimersByTimeAsync(1000); + const error = await settled; + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.RequestTimeout); + // wireTeardown sent notifications/cancelled referencing the listen id. + const listenId = (written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { id: number | string }).id; + const cancelled = written.find(m => (m as JSONRPCNotification).method === 'notifications/cancelled'); + expect(cancelled).toMatchObject({ params: { requestId: listenId } }); + // No leaked state. + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + expect((client as unknown as { _responseHandlers: Map })._responseHandlers.size).toBe(0); + // Restore real timers before close to avoid hanging on transport timers. + vi.useRealTimers(); + await client.close(); + }); +}); diff --git a/packages/client/test/client/mcpParamMirroring.test.ts b/packages/client/test/client/mcpParamMirroring.test.ts new file mode 100644 index 0000000000..1d8752d469 --- /dev/null +++ b/packages/client/test/client/mcpParamMirroring.test.ts @@ -0,0 +1,505 @@ +/** + * SEP-2243 client-side `Mcp-Param-*` mirroring (protocol revision 2026-07-28). + * + * Covers: `tools/list` exclusion of constraint-violating definitions; per-call + * `Mcp-Param-*` header construction from the response-cache's `tools/list` + * entry and the `toolDefinition` escape hatch; era-parity (legacy `callTool` + * byte-untouched); stdio MAY-ignore (no headers on a single-channel + * transport); the one-evict-refetch-retry on `HEADER_MISMATCH`. + */ +import type { JSONRPCMessage, JSONRPCRequest, Tool, TransportSendOptions } from '@modelcontextprotocol/core'; +import { encodeMcpParamValue, HEADER_MISMATCH_ERROR_CODE, InMemoryTransport, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { Client } from '../../src/client/client.js'; +import { InMemoryResponseCacheStore, type ResponseCacheStore } from '../../src/client/responseCache.js'; +import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp.js'; + +const MODERN = '2026-07-28'; +/** Partition the `Client` derives for the scripted server (`serverInfo.name@version`, default `cachePartition`). */ +const PART = JSON.stringify(['scripted@1.0.0', '']); + +const REGION_TOOL: Tool = { + name: 'route', + inputSchema: { + type: 'object', + properties: { region: { type: 'string', 'x-mcp-header': 'Region' }, query: { type: 'string' } } + } +}; + +const INVALID_TOOL: Tool = { + name: 'broken', + inputSchema: { type: 'object', properties: { a: { type: 'object', 'x-mcp-header': 'Data' } } } +}; + +interface Scripted { + clientTx: InMemoryTransport; + serverTx: InMemoryTransport; + /** Headers passed via TransportSendOptions for each tools/call (undefined when none). */ + callHeaders: Array | undefined>; + listCount: () => number; +} + +async function scriptedModernServer(pages: Tool[][], rejectFirstCall = false): Promise { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const callHeaders: Array | undefined> = []; + let calls = 0; + let lists = 0; + + // Tap the client→server channel to observe TransportSendOptions.headers + // (InMemoryTransport ignores it; this is the seam under test). + const realSend = clientTx.send.bind(clientTx); + clientTx.send = (m: JSONRPCMessage, opts?: TransportSendOptions): Promise => { + if ((m as JSONRPCRequest).method === 'tools/call') { + callHeaders.push(opts?.headers ? { ...opts.headers } : undefined); + } + return realSend(m, opts); + }; + + serverTx.onmessage = m => { + const r = m as JSONRPCRequest; + if (r.id === undefined) return; + if (r.method === 'server/discover') { + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 'scripted', version: '1.0.0' } + } + }); + } else if (r.method === 'tools/list') { + lists++; + const cursor = (r.params as { cursor?: string } | undefined)?.cursor; + const idx = cursor === undefined ? 0 : Number(cursor); + const next = idx + 1 < pages.length ? String(idx + 1) : undefined; + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { + resultType: 'complete', + ttlMs: 60_000, + cacheScope: 'public', + tools: pages[idx] ?? [], + ...(next !== undefined && { nextCursor: next }) + } + }); + } else if (r.method === 'tools/call') { + calls++; + if (rejectFirstCall && calls === 1) { + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + error: { code: HEADER_MISMATCH_ERROR_CODE, message: 'Bad Request: the request headers and body disagree' } + }); + } else { + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { resultType: 'complete', content: [{ type: 'text', text: 'ok' }] } + }); + } + } + }; + await serverTx.start(); + return { clientTx, serverTx, callHeaders, listCount: () => lists }; +} + +function modernClient(store?: InMemoryResponseCacheStore): Client { + return new Client( + { name: 'param-mirror-client', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: MODERN } }, ...(store && { responseCacheStore: store }) } + ); +} + +describe('SEP-2243 Mcp-Param-* mirroring (modern era)', () => { + it('listTools() and the cached tools/list entry exclude constraint-violating x-mcp-header tools and warn', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const store = new InMemoryResponseCacheStore(); + const { clientTx } = await scriptedModernServer([[REGION_TOOL, INVALID_TOOL]]); + const client = modernClient(store); + await client.connect(clientTx); + + // Auto-aggregate listTools() filters and writes the CACHED aggregate + // (the entry mirroring reads). + const { tools } = await client.listTools(); + expect(tools.map(t => t.name)).toEqual(['route']); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("excluding tool 'broken'")); + expect((store.get({ method: 'tools/list', partition: PART })?.value as { tools: Tool[] }).tools.map(t => t.name)).toEqual([ + 'route' + ]); + // The explicit-cursor per-page path is filtered too (the spec's MUST + // has no carve-out for paginated reads). + const page = await client.listTools({ cursor: '0' }); + expect(page.tools.map(t => t.name)).toEqual(['route']); + warn.mockRestore(); + }); + + it('callTool() passes Mcp-Param-* via TransportSendOptions.headers from the cached tools/list entry; null/absent are omitted', async () => { + const { clientTx, callHeaders } = await scriptedModernServer([[REGION_TOOL]]); + const client = modernClient(); + await client.connect(clientTx); + await client.listTools(); + + await client.callTool({ name: 'route', arguments: { region: 'us-west1', query: 'x' } }); + await client.callTool({ name: 'route', arguments: { region: null, query: 'x' } as Record }); + + expect(callHeaders[0]).toEqual({ 'Mcp-Param-Region': 'us-west1' }); + expect(callHeaders[1]).toBeUndefined(); + }); + + it('callTool() uses the toolDefinition escape hatch without a prior tools/list', async () => { + const { clientTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]]); + const client = modernClient(); + await client.connect(clientTx); + + await client.callTool({ name: 'route', arguments: { region: 'eu' } }, { toolDefinition: REGION_TOOL }); + expect(listCount()).toBe(0); + expect(callHeaders[0]).toEqual({ 'Mcp-Param-Region': 'eu' }); + }); + + it('callTool() evicts the tools/list entry, refetches once and retries on a HEADER_MISMATCH rejection (stale-cache path)', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]], /* rejectFirstCall */ true); + const client = modernClient(store); + await client.connect(clientTx); + // Seed a STALE entry at the connected-server partition (a STALE + // declaration on `region`) so callTool reads IT and the first send + // carries the stale `Mcp-Param-Stale-Region` header — server rejects + // HEADER_MISMATCH, client evicts, refetches via listTools() + // (the live REGION_TOOL), and retries with the correct header. + store.set( + { method: 'tools/list', partition: PART }, + { + value: { + tools: [ + { + name: 'route', + inputSchema: { type: 'object', properties: { region: { type: 'string', 'x-mcp-header': 'Stale-Region' } } } + } + ] + } + } + ); + + const result = await client.callTool({ name: 'route', arguments: { region: 'ap' } }); + expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' }); + expect(listCount()).toBe(1); + // First send mirrored the SEEDED stale declaration (proves the + // stale-cache read path, not cold-cache); retry mirrored the live one. + expect(callHeaders).toEqual([{ 'Mcp-Param-Stale-Region': 'ap' }, { 'Mcp-Param-Region': 'ap' }]); + // The recovery refetch wrote a fresh cache entry (REGION_TOOL, with the declaration). + expect( + (store.get({ method: 'tools/list', partition: PART })?.value as { tools: Tool[] }).tools[0]?.inputSchema.properties + ).toHaveProperty('region'); + }); + + it("HEADER_MISMATCH recovery refetch reaches the wire even when the store's delete() no-ops (cacheMode:'refresh' bypasses the stale entry)", async () => { + // A custom store whose `delete()` is a no-op (or rejects) leaves the + // stale `tools/list` entry in place after `evict()`. The recovery + // refetch must NOT be cache-served that stale entry — it carries + // `cacheMode: 'refresh'` so it always reaches the wire and overwrites. + const store = new InMemoryResponseCacheStore(); + (store as ResponseCacheStore).delete = () => undefined; + const { clientTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]], /* rejectFirstCall */ true); + const client = modernClient(store); + await client.connect(clientTx); + // Seed a STALE-and-fresh entry (the declaration mirrors as + // `Stale-Region`; expiresAt in the future so a default-mode + // `listTools()` WOULD serve it if not for `'refresh'`). + store.set( + { method: 'tools/list', partition: PART }, + { + value: { + tools: [ + { + name: 'route', + inputSchema: { type: 'object', properties: { region: { type: 'string', 'x-mcp-header': 'Stale-Region' } } } + } + ] + }, + expiresAt: Date.now() + 60_000, + scope: 'public' + } + ); + + const result = await client.callTool({ name: 'route', arguments: { region: 'ap' } }); + expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' }); + // The refetch hit the wire (delete() no-op did NOT short-circuit it + // into a cache serve of the stale seed). + expect(listCount()).toBe(1); + // Retry mirrored the LIVE declaration, not the stale seed. + expect(callHeaders).toEqual([{ 'Mcp-Param-Stale-Region': 'ap' }, { 'Mcp-Param-Region': 'ap' }]); + // The refetch's write overwrote the stale entry (the no-op delete + // never dropped it; the `'refresh'` write replaced it). + expect( + (store.get({ method: 'tools/list', partition: PART })?.value as { tools: Tool[] }).tools[0]?.inputSchema.properties + ).toHaveProperty(['region', 'x-mcp-header'], 'Region'); + }); + + it('callTool() with a cold cache issues NO tools/list and sends without Mcp-Param-* headers (cache reads only)', async () => { + const { clientTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]]); + const client = modernClient(); + await client.connect(clientTx); + + const result = await client.callTool({ name: 'route', arguments: { region: 'ap' } }); + expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' }); + // No on-demand populate: callTool reads the cache directly. Cold ⇒ + // proceed without headers (the spec's "client SHOULD send without + // custom headers" guidance) — the only callTool-driven tools/list is + // the HEADER_MISMATCH recovery path. + expect(listCount()).toBe(0); + expect(callHeaders).toEqual([undefined]); + }); + + it('a custom store whose get() rejects is routed to onerror and callTool degrades (no headers, no validation, result preserved)', async () => { + const store = new InMemoryResponseCacheStore(); + (store as ResponseCacheStore).get = () => Promise.reject(new Error('redis down')); + const { clientTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]]); + const client = modernClient(store); + const errors: Error[] = []; + client.onerror = e => errors.push(e); + await client.connect(clientTx); + + // The pre-send mirroring read AND the post-success validator read both + // hit a rejecting `get()`. Neither aborts the call: the request goes + // out without `Mcp-Param-*` headers (cold-cache posture), the + // server-side result is returned, and both store failures surface via + // `onerror`. The post-success guard is the critical one — a store + // failure after the server has executed the call must never surface + // as a `callTool()` rejection (duplicate-execution hazard on retry). + const result = await client.callTool({ name: 'route', arguments: { region: 'ap' } }); + expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' }); + expect(callHeaders).toEqual([undefined]); + expect(listCount()).toBe(0); + expect(errors.map(e => e.message)).toEqual(['redis down', 'redis down']); + }); + + it('a paginating server: the cached aggregate holds every page and a page-2 x-mcp-header tool mirrors on the first call', async () => { + const PAGE1: Tool = { name: 'echo', inputSchema: { type: 'object', properties: {} } }; + const { clientTx, callHeaders, listCount } = await scriptedModernServer([[PAGE1], [REGION_TOOL]]); + const client = modernClient(); + await client.connect(clientTx); + + const { tools } = await client.listTools(); + expect(tools.map(t => t.name)).toEqual(['echo', 'route']); + expect(listCount()).toBe(2); + + await client.callTool({ name: 'route', arguments: { region: 'us-west1' } }); + expect(callHeaders[0]).toEqual({ 'Mcp-Param-Region': 'us-west1' }); + }); + + it('HEADER_MISMATCH recovery refetch walks every page; a page-2 x-mcp-header tool is recovered (stale-cache path)', async () => { + const PAGE1: Tool = { name: 'echo', inputSchema: { type: 'object', properties: {} } }; + const store = new InMemoryResponseCacheStore(); + const { clientTx, callHeaders, listCount } = await scriptedModernServer([[PAGE1], [REGION_TOOL]], /* rejectFirstCall */ true); + const client = modernClient(store); + await client.connect(clientTx); + // Seed a STALE entry at the connected-server partition (one stale + // page; `route` carries a STALE declaration) so callTool reads it, + // mirrors the stale header on the first send, and the recovery + // refetch (via listTools()) then walks BOTH live pages. + store.set( + { method: 'tools/list', partition: PART }, + { + value: { + tools: [ + PAGE1, + { + name: 'route', + inputSchema: { type: 'object', properties: { region: { type: 'string', 'x-mcp-header': 'Stale-Region' } } } + } + ] + } + } + ); + + const result = await client.callTool({ name: 'route', arguments: { region: 'us-west1' } }); + expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' }); + // The recovery refetch walked both pages. + expect(listCount()).toBe(2); + // First send mirrored the SEEDED stale declaration (proves the + // stale-cache read path); retry mirrored the live page-2 declaration. + expect(callHeaders).toEqual([{ 'Mcp-Param-Stale-Region': 'us-west1' }, { 'Mcp-Param-Region': 'us-west1' }]); + // A follow-up call still mirrors from the cached entry (no extra list). + await client.callTool({ name: 'route', arguments: { region: 'eu' } }); + expect(callHeaders[2]).toEqual({ 'Mcp-Param-Region': 'eu' }); + expect(listCount()).toBe(2); + }); + + it('notifications/tools/list_changed evicts the cached entry; the next callTool reads cold (no auto-refetch)', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, serverTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]]); + const client = modernClient(store); + await client.connect(clientTx); + // Seed a STALE entry at the connected-server partition; list_changed + // evicts it (partition-scoped delete); the next callTool reads cold + // and sends without headers — callTool never refetches on its own. + store.set( + { method: 'tools/list', partition: PART }, + { value: { tools: [{ name: 'route', inputSchema: { type: 'object', properties: {} } }] } } + ); + expect(store.get({ method: 'tools/list', partition: PART })).toBeDefined(); + await serverTx.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' } as JSONRPCMessage); + expect(store.get({ method: 'tools/list', partition: PART })).toBeUndefined(); + + const result = await client.callTool({ name: 'route', arguments: { region: 'us' } }); + expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' }); + expect(listCount()).toBe(0); + expect(callHeaders).toEqual([undefined]); + }); + + it('_resetConnectionState() clears the response cache (close → reconnect → no stale scan)', async () => { + const a = await scriptedModernServer([[REGION_TOOL]]); + const client = modernClient(); + await client.connect(a.clientTx); + await client.listTools(); + await client.close(); + + const b = await scriptedModernServer([[{ name: 'route', inputSchema: { type: 'object', properties: {} } }]]); + await client.connect(b.clientTx); + + await client.callTool({ name: 'route', arguments: { region: 'us' } }); + // The cache from A was cleared on close → callTool reads cold against + // server B → no Mcp-Param-* headers (no stale scan from A's entry), + // and no callTool-driven tools/list either. + expect(b.listCount()).toBe(0); + expect(b.callHeaders[0]).toBeUndefined(); + }); +}); + +describe('SEP-2243 era parity / stdio exemption', () => { + it('legacy-era callTool() is byte-untouched: zero tools/list requests, no headers, no exclusion', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const callHeaders: Array | undefined> = []; + const sentMethods: string[] = []; + const realSend = clientTx.send.bind(clientTx); + clientTx.send = (m: JSONRPCMessage, opts?: TransportSendOptions): Promise => { + if ('method' in m) sentMethods.push((m as JSONRPCRequest).method); + if ((m as JSONRPCRequest).method === 'tools/call') callHeaders.push(opts?.headers ? { ...opts.headers } : undefined); + return realSend(m, opts); + }; + serverTx.onmessage = m => { + const r = m as JSONRPCRequest; + if (r.id === undefined) return; + if (r.method === 'initialize') { + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { protocolVersion: '2025-11-25', capabilities: { tools: {} }, serverInfo: { name: 's', version: '1' } } + }); + } else if (r.method === 'tools/list') { + void serverTx.send({ jsonrpc: '2.0', id: r.id, result: { tools: [REGION_TOOL, INVALID_TOOL] } }); + } else if (r.method === 'tools/call') { + void serverTx.send({ jsonrpc: '2.0', id: r.id, result: { content: [{ type: 'text', text: 'ok' }] } }); + } + }; + await serverTx.start(); + + const client = new Client({ name: 'legacy', version: '1' }); + await client.connect(clientTx); + expect(client.getProtocolEra()).toBe('legacy'); + + // PIN: a legacy/stdio callTool issues ZERO tools/list requests — + // callTool never auto-populates the cache; mirroring/validation read + // it directly (cold ⇒ skip). + await client.callTool({ name: 'route', arguments: { region: 'us' } }); + expect(sentMethods.filter(m => m === 'tools/list')).toEqual([]); + expect(callHeaders).toEqual([undefined]); + + const { tools } = await client.listTools(); + // No exclusion on the legacy era — both tools present. + expect(tools.map(t => t.name)).toEqual(['route', 'broken']); + }); + + it('modern-era stdio callTool() issues zero tools/list requests (cold cache, mirroring inactive)', async () => { + // Mirrors the legacy pin above but on the modern era over a + // single-channel transport: even though `mirroringActive` is true, + // callTool reads the cache directly and sends nothing extra. + const { clientTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]]); + const client = modernClient(); + await client.connect(clientTx); + + await client.callTool({ name: 'route', arguments: { region: 'us' } }); + expect(listCount()).toBe(0); + expect(callHeaders).toEqual([undefined]); + }); + + it('stdio MAY-ignore: a single-channel transport drops TransportSendOptions.headers', async () => { + // InMemoryTransport stands in for stdio here: like the stdio transport + // it shares a single channel and ignores per-request HTTP headers. + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + let sawHeaders: unknown; + serverTx.onmessage = (_m, extra) => { + sawHeaders = (extra as { headers?: unknown } | undefined)?.headers; + }; + await clientTx.start(); + await (clientTx as { send: (m: JSONRPCMessage, opts?: TransportSendOptions) => Promise }).send( + { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'x' } }, + { headers: { 'Mcp-Param-Region': 'us' } } + ); + expect(sawHeaders).toBeUndefined(); + }); +}); + +describe('SEP-2243 Streamable HTTP transport seams', () => { + function transportWithCapture(): { tx: StreamableHTTPClientTransport; sent: () => Headers } { + let captured: Headers | undefined; + const fetch = vi.fn(async (_url, init) => { + captured = new Headers((init as RequestInit).headers); + return new Response(null, { status: 202, headers: { 'content-type': 'application/json' } }); + }); + const tx = new StreamableHTTPClientTransport(new URL('http://example.test/mcp'), { fetch: fetch as typeof globalThis.fetch }); + return { tx, sent: () => captured! }; + } + + const modernRequest = (method: string, params: Record): JSONRPCMessage => ({ + jsonrpc: '2.0', + id: 1, + method, + params: { ...params, _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN } } + }); + + it('Mcp-Name is sentinel-encoded for non-ASCII / unsafe values (no Headers.set TypeError)', async () => { + const { tx, sent } = transportWithCapture(); + await tx.start(); + await tx.send(modernRequest('resources/read', { uri: 'file:///レポート.md' })); + expect(sent().get('mcp-name')).toBe(encodeMcpParamValue('file:///レポート.md')); + // ASCII-safe values pass through unchanged. + await tx.send(modernRequest('tools/call', { name: 'route', arguments: {} })); + expect(sent().get('mcp-name')).toBe('route'); + }); + + it('per-request TransportSendOptions.headers cannot override reserved standard/auth headers', async () => { + const { tx, sent } = transportWithCapture(); + await tx.start(); + await tx.send(modernRequest('tools/call', { name: 'route', arguments: {} }), { + headers: { 'Mcp-Method': 'tools/list', authorization: 'Bearer evil', 'Mcp-Param-Region': 'us' } + }); + expect(sent().get('mcp-method')).toBe('tools/call'); + expect(sent().get('authorization')).toBeNull(); + expect(sent().get('mcp-param-region')).toBe('us'); + }); + + it('an HTTP 400 carrying a JSON-RPC error response is delivered in-band on a modern-enveloped request; legacy still throws SdkHttpError', async () => { + const errorBody = { jsonrpc: '2.0', id: 1, error: { code: HEADER_MISMATCH_ERROR_CODE, message: 'Bad Request: …' } }; + const fetch = vi.fn( + async () => new Response(JSON.stringify(errorBody), { status: 400, headers: { 'content-type': 'application/json' } }) + ); + const tx = new StreamableHTTPClientTransport(new URL('http://example.test/mcp'), { fetch: fetch as typeof globalThis.fetch }); + const seen: JSONRPCMessage[] = []; + tx.onmessage = m => seen.push(m); + await tx.start(); + await expect(tx.send(modernRequest('tools/call', { name: 'route', arguments: {} }))).resolves.toBeUndefined(); + expect(seen[0]).toMatchObject({ id: 1, error: { code: HEADER_MISMATCH_ERROR_CODE } }); + + // Legacy-era exchange (no envelope claim) still surfaces 400 as the + // generic SdkHttpError — gating keeps the "legacy paths unchanged" + // claim true. + await expect(tx.send({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'route' } })).rejects.toMatchObject({ + status: 400 + }); + }); +}); diff --git a/packages/client/test/client/modernEraInboundDrop.test.ts b/packages/client/test/client/modernEraInboundDrop.test.ts new file mode 100644 index 0000000000..fbdc1f0af0 --- /dev/null +++ b/packages/client/test/client/modernEraInboundDrop.test.ts @@ -0,0 +1,145 @@ +/** + * 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('refuses a wire elicitation/create request on a modern connection even when an elicitation handler is registered (the in-band vocabulary grants no wire dispatch)', async () => { + const { clientTx, serverTx, written } = await scriptedServerSide('modern'); + const client = new Client( + { name: 'drop-client', version: '1.0.0' }, + { versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { form: {} } } } + ); + const handled: unknown[] = []; + client.setRequestHandler('elicitation/create', async request => { + handled.push(request.params); + return { action: 'accept', content: {} }; + }); + const errors: Error[] = []; + client.onerror = error => void errors.push(error); + await client.connect(clientTx); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + const before = written.length; + // elicitation/create exists on the 2026-07-28 era only as in-band + // (embedded) vocabulary inside input_required results. A wire request + // for it must never reach the registered handler or be answered with a + // result — the era gate is not bypassed by the in-band schema fallback. + await serverTx.send({ + jsonrpc: '2.0', + id: 'rogue-elicit-1', + method: 'elicitation/create', + params: { mode: 'form', message: 'Name?', requestedSchema: { type: 'object', properties: {} } } + }); + await flush(); + + expect(handled).toHaveLength(0); + 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..2991acd3d0 --- /dev/null +++ b/packages/client/test/client/probeClassifier.test.ts @@ -0,0 +1,313 @@ +/** + * 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: -32022 + valid data.supported with a mutual modern version → select-and-continue, MUST NOT fall back', () => { + test('in-band -32022 yields a corrective verdict (never legacy)', () => { + const verdict = classify({ + kind: 'rpc-error', + code: -32_022, + message: 'Unsupported protocol version', + data: { supported: [MODERN], requested: '2027-01-01' } + }); + expect(verdict).toMatchObject({ kind: 'corrective', version: MODERN }); + }); + + test('HTTP 400-bodied -32022 yields the same corrective verdict', () => { + const verdict = classify({ + kind: 'http-error', + status: 400, + body: httpErrorBody(-32_022, '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_022, + 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: -32022 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_022, + 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: -32022 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_022, + 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_022, 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('-32022 with malformed data (no valid supported list) → conservative legacy', () => { + expect(classify({ kind: 'rpc-error', code: -32_022, message: 'nope', data: { supported: 'not-a-list' } })).toEqual({ + kind: 'legacy' + }); + expect(classify({ kind: 'rpc-error', code: -32_022, 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 / -32020 / -32021 are NEVER probe-recognized → fall into unrecognized → legacy', () => { + test('-32001 (session-404 overload on deployed servers — the SDK-conventional code, 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('-32020 (the spec-assigned HeaderMismatch code is still never probe evidence)', () => { + expect(classify({ kind: 'rpc-error', code: -32_020, message: 'Header mismatch' })).toEqual({ kind: 'legacy' }); + expect(classify({ kind: 'http-error', status: 400, body: httpErrorBody(-32_020, 'Header mismatch') })).toEqual({ + kind: 'legacy' + }); + }); + + test('-32021 with data is NOT modern evidence', () => { + expect(classify({ kind: 'rpc-error', code: -32_021, 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..bb02c76fa9 --- /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 server-side opening classification (the era a connection's first + * exchange selects) is bound by `packages/server/test/server/serveStdio.test.ts`. + * + * 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: -32022 with a structured supported list naming a mutual modern version → corrective continuation', + outcome: { + kind: 'rpc-error', + code: -32_022, + message: 'Unsupported protocol version', + data: { supported: [MODERN, LATEST_PROTOCOL_VERSION], requested: '2027-01-01' } + }, + expected: 'corrective' + }, + { + name: 'recognizer: -32022 without a parsable data.supported list is not actionable modern evidence → legacy', + outcome: { kind: 'rpc-error', code: -32_022, message: 'Unsupported protocol version' }, + expected: 'legacy' + }, + { + name: 'recognizer: -32022 with a legacy-only supported list is a definitive legacy signal → legacy', + outcome: { + kind: 'rpc-error', + code: -32_022, + 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/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts new file mode 100644 index 0000000000..c10cb521a3 --- /dev/null +++ b/packages/client/test/client/responseCache.test.ts @@ -0,0 +1,1268 @@ +/** + * Response-cache substrate: store primitives, the {@linkcode ClientResponseCache} + * coordinator, and the Client's wiring (mcp.d's `cachedTool` pattern). + * + * Covers: `list*` auto-aggregation writing one entry; `list_changed` evicts + * (does not refetch); `resetForReconnect` respects the user-supplied flag; + * `toolDefinition` hit/miss and re-derivation only on a stamp change; the + * generation guard skipping a stale write. + */ +import type { JSONRPCMessage, JSONRPCRequest, Tool } from '@modelcontextprotocol/core'; +import { InMemoryTransport, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { Client } from '../../src/client/client.js'; +import type { CacheEntry, ResponseCacheStore } from '../../src/client/responseCache.js'; +import { ClientResponseCache, InMemoryResponseCacheStore } from '../../src/client/responseCache.js'; + +const MODERN = '2026-07-28'; + +const TOOL_A: Tool = { name: 'a', inputSchema: { type: 'object', properties: {} } }; +const TOOL_B: Tool = { name: 'b', inputSchema: { type: 'object', properties: {} } }; + +/** + * Partition the `Client` derives for the scripted server (`serverInfo: + * {name:'scripted', version:'1.0.0'}`) and `principal` (default `''` ⇒ the + * server's shared/public slot). The encoding is the same JSON-array form + * `ClientResponseCache._partitionFor` produces. + */ +const part = (principal = '', serverIdentity = 'scripted@1.0.0'): string => JSON.stringify([serverIdentity, principal]); +/** The pre-connect / direct-`ClientResponseCache` sentinel partition (`['', '']`). */ +const PRE = JSON.stringify(['', '']); + +describe('InMemoryResponseCacheStore', () => { + it('get/set/evict/clear round-trip; evict is method-scoped; set returns the store-generated stamp', () => { + const store = new InMemoryResponseCacheStore(); + const s1 = store.set({ method: 'tools/list' }, { value: 1 }); + const s2 = store.set({ method: 'prompts/list' }, { value: 2 }); + const s3 = store.set({ method: 'resources/read', params: 'file:///a' }, { value: 3, expiresAt: 123, scope: 'private' }); + // Store owns the stamp counter: monotonic, opaque to callers, surfaced on the entry. + expect(s2).toBeGreaterThan(s1); + expect(s3).toBeGreaterThan(s2); + expect(store.get({ method: 'tools/list' })).toEqual({ value: 1, stamp: s1 }); + // Store persists caller-supplied freshness metadata. + expect(store.get({ method: 'resources/read', params: 'file:///a' })).toEqual({ + value: 3, + stamp: s3, + expiresAt: 123, + scope: 'private' + }); + expect(store.get({ method: 'tools/list', params: '', partition: '' })?.value).toBe(1); + store.evict('tools/list'); + expect(store.get({ method: 'tools/list' })).toBeUndefined(); + expect(store.get({ method: 'prompts/list' })?.value).toBe(2); + expect(store.get({ method: 'resources/read', params: 'file:///a' })?.value).toBe(3); + store.clear(); + expect(store.get({ method: 'prompts/list' })).toBeUndefined(); + }); + + it('partition is part of the key serialization; evict(method) is partition-agnostic', () => { + const store = new InMemoryResponseCacheStore(); + store.set({ method: 'tools/list', partition: 'p1' }, { value: 'a' }); + store.set({ method: 'tools/list', partition: 'p2' }, { value: 'b' }); + expect(store.get({ method: 'tools/list', partition: 'p1' })?.value).toBe('a'); + expect(store.get({ method: 'tools/list', partition: 'p2' })?.value).toBe('b'); + // The default-partition slot is distinct. + expect(store.get({ method: 'tools/list' })).toBeUndefined(); + // evict(method) is partition-agnostic. + store.evict('tools/list'); + expect(store.get({ method: 'tools/list', partition: 'p1' })).toBeUndefined(); + expect(store.get({ method: 'tools/list', partition: 'p2' })).toBeUndefined(); + }); + + it('keyOf is collision-free for NUL / quote / delimiter characters in partition and params', () => { + const store = new InMemoryResponseCacheStore(); + // A NUL in `partition` cannot smuggle into `params` (and vice versa) — + // the `[partition, params]` JSON-array encoding escapes every control + // and quote character. + store.set({ method: 'resources/read', partition: 'a\0b', params: 'c' }, { value: 1 }); + store.set({ method: 'resources/read', partition: 'a', params: 'b\0c' }, { value: 2 }); + expect(store.get({ method: 'resources/read', partition: 'a\0b', params: 'c' })?.value).toBe(1); + expect(store.get({ method: 'resources/read', partition: 'a', params: 'b\0c' })?.value).toBe(2); + // Same for the partition's own JSON-shaped content: the outer + // JSON.stringify escapes the inner quotes. + store.set({ method: 'tools/list', partition: '["x",""]' }, { value: 'real' }); + store.set({ method: 'tools/list', partition: '["x","' }, { value: 'spoof' }); + expect(store.get({ method: 'tools/list', partition: '["x",""]' })?.value).toBe('real'); + }); + + it('maxEntries cap: oldest-first eviction; re-set of an existing key never evicts; 0 disables the bound', () => { + const small = new InMemoryResponseCacheStore({ maxEntries: 2 }); + small.set({ method: 'resources/read', params: 'a' }, { value: 'a' }); + small.set({ method: 'resources/read', params: 'b' }, { value: 'b' }); + // Re-set of an existing key updates in place without consuming + // capacity (Map preserves the original insertion position). + small.set({ method: 'resources/read', params: 'a' }, { value: 'a2' }); + expect(small.size).toBe(2); + expect(small.get({ method: 'resources/read', params: 'a' })?.value).toBe('a2'); + // A NEW key at capacity evicts the oldest insertion ('a'). + small.set({ method: 'resources/read', params: 'c' }, { value: 'c' }); + expect(small.get({ method: 'resources/read', params: 'a' })).toBeUndefined(); + expect(small.get({ method: 'resources/read', params: 'b' })?.value).toBe('b'); + expect(small.get({ method: 'resources/read', params: 'c' })?.value).toBe('c'); + expect(small.size).toBe(2); + + // 0 disables the bound. + const unbounded = new InMemoryResponseCacheStore({ maxEntries: 0 }); + for (let i = 0; i < 1000; i++) unbounded.set({ method: 'resources/read', params: String(i) }, { value: i }); + expect(unbounded.size).toBe(1000); + }); + + it('maxEntries cap exempts list-singleton methods: a resources/read flood never evicts tools/list', () => { + const store = new InMemoryResponseCacheStore({ maxEntries: 3 }); + // List singletons are exempt: never counted, never evicted by the cap. + store.set({ method: 'tools/list' }, { value: 'T' }); + store.set({ method: 'prompts/list' }, { value: 'P' }); + store.set({ method: 'resources/list' }, { value: 'R' }); + store.set({ method: 'resources/templates/list' }, { value: 'RT' }); + store.set({ method: 'server/discover' }, { value: 'D' }); + // Five exempt entries already exceed maxEntries=3; a resources/read + // write does NOT evict any of them — the cap counts only non-exempt + // keys. + for (let i = 0; i < 5; i++) store.set({ method: 'resources/read', params: String(i) }, { value: i }); + expect(store.get({ method: 'tools/list' })?.value).toBe('T'); + expect(store.get({ method: 'prompts/list' })?.value).toBe('P'); + expect(store.get({ method: 'resources/list' })?.value).toBe('R'); + expect(store.get({ method: 'resources/templates/list' })?.value).toBe('RT'); + expect(store.get({ method: 'server/discover' })?.value).toBe('D'); + // Only 3 resources/read entries survive (oldest two evicted). + expect(store.get({ method: 'resources/read', params: '0' })).toBeUndefined(); + expect(store.get({ method: 'resources/read', params: '1' })).toBeUndefined(); + expect(store.get({ method: 'resources/read', params: '2' })?.value).toBe(2); + expect(store.get({ method: 'resources/read', params: '4' })?.value).toBe(4); + expect(store.size).toBe(8); + // An exempt-method write at capacity never evicts a resources/read entry. + store.set({ method: 'tools/list', partition: 'p2' }, { value: 'T2' }); + expect(store.get({ method: 'resources/read', params: '2' })?.value).toBe(2); + }); + + it('delete(key) drops the single entry; no-op when absent', () => { + const store = new InMemoryResponseCacheStore(); + store.set({ method: 'resources/read', params: 'a', partition: 'p' }, { value: 1 }); + store.set({ method: 'resources/read', params: 'b', partition: 'p' }, { value: 2 }); + store.delete({ method: 'resources/read', params: 'a', partition: 'p' }); + expect(store.get({ method: 'resources/read', params: 'a', partition: 'p' })).toBeUndefined(); + expect(store.get({ method: 'resources/read', params: 'b', partition: 'p' })?.value).toBe(2); + // Absent key: no-op. + store.delete({ method: 'resources/read', params: 'a', partition: 'p' }); + }); +}); + +describe('ClientResponseCache', () => { + it('write skips when the captured generation moved (list_changed-during-walk guard)', async () => { + const store = new InMemoryResponseCacheStore(); + const cache = new ClientResponseCache(store, false); + const gen = cache.captureGeneration('tools/list'); + await cache.evict('tools/list'); + await cache.write('tools/list', { tools: [TOOL_A] }, gen); + // Generation moved between capture and write → the stale aggregate is dropped. + expect(store.get({ method: 'tools/list', partition: PRE })).toBeUndefined(); + // A fresh capture after the evict writes through. + const gen2 = cache.captureGeneration('tools/list'); + await cache.write('tools/list', { tools: [TOOL_A] }, gen2); + expect(store.get({ method: 'tools/list', partition: PRE })).toBeDefined(); + }); + + it('resetForReconnect: clears the default store, leaves a user-supplied store, ALWAYS drops generation + indices', async () => { + // User-supplied: store survives, generation map + derived index are dropped. + const userStore = new InMemoryResponseCacheStore(); + const userCache = new ClientResponseCache(userStore, true); + await userCache.write('tools/list', { tools: [TOOL_A] }, userCache.captureGeneration('tools/list')); + expect((await userCache.toolDefinition('a'))?.name).toBe('a'); + await userCache.evict('prompts/list'); + expect(userCache.captureGeneration('prompts/list')).toBe(1); + userCache.resetForReconnect(); + expect(userStore.get({ method: 'tools/list', partition: PRE })).toBeDefined(); + expect(userCache.captureGeneration('prompts/list')).toBe(0); + // Index dropped → re-derived from the (still-populated) store on next read. + expect((userCache as unknown as { _toolIndex?: unknown })._toolIndex).toBeUndefined(); + expect((await userCache.toolDefinition('a'))?.name).toBe('a'); + + // Default: store is cleared. + const defStore = new InMemoryResponseCacheStore(); + const defCache = new ClientResponseCache(defStore, false); + await defCache.write('tools/list', { tools: [TOOL_A] }, defCache.captureGeneration('tools/list')); + defCache.resetForReconnect(); + expect(defStore.get({ method: 'tools/list', partition: PRE })).toBeUndefined(); + expect(await defCache.toolDefinition('a')).toBeUndefined(); + }); + + it('write stores a defensive copy: caller-side mutation cannot reach the cache or its derived index', async () => { + const store = new InMemoryResponseCacheStore(); + const cache = new ClientResponseCache(store, false); + const value = { tools: [{ ...TOOL_A }, { ...TOOL_B }] }; + await cache.write('tools/list', value, cache.captureGeneration('tools/list')); + // Mutate the caller's reference (the same object _listAllPages returns). + value.tools.length = 0; + // The cached entry is a structuredClone, so the store and the + // stamp-memoized index are unaffected. + expect((store.get({ method: 'tools/list', partition: PRE })?.value as { tools: Tool[] }).tools.map(t => t.name)).toEqual([ + 'a', + 'b' + ]); + expect((await cache.toolDefinition('a'))?.name).toBe('a'); + expect((await cache.toolDefinition('b'))?.name).toBe('b'); + }); + + it('evictKey bumps the per-key generation so an in-flight write for the same {method, params} is suppressed', async () => { + const store = new InMemoryResponseCacheStore(); + const cache = new ClientResponseCache(store, false); + // Capture BEFORE the request; evictKey lands mid-flight; the stale + // write is dropped (mirrors the list_changed-during-walk guard, + // keyed per URI). + const gen = cache.captureGeneration('resources/read', 'res://a'); + await cache.evictKey('resources/read', 'res://a'); + await cache.write('resources/read', { contents: [] }, gen, { expiresAt: 1e9, scope: 'private', params: 'res://a' }); + expect(store.get({ method: 'resources/read', params: 'res://a', partition: PRE })).toBeUndefined(); + // A sibling URI's generation is independent: evictKey('a') does not + // suppress a write for 'b'. + const genB = cache.captureGeneration('resources/read', 'res://b'); + await cache.evictKey('resources/read', 'res://a'); + await cache.write('resources/read', { contents: [] }, genB, { expiresAt: 1e9, scope: 'private', params: 'res://b' }); + expect(store.get({ method: 'resources/read', params: 'res://b', partition: PRE })).toBeDefined(); + // A fresh capture after the evictKey writes through. + const gen2 = cache.captureGeneration('resources/read', 'res://a'); + await cache.write('resources/read', { contents: [] }, gen2, { expiresAt: 1e9, scope: 'private', params: 'res://a' }); + expect(store.get({ method: 'resources/read', params: 'res://a', partition: PRE })).toBeDefined(); + }); + + it('evictKey: own-partition store.delete rejecting does not skip the shared-partition delete', async () => { + const deleted: string[] = []; + const store: ResponseCacheStore = { + get: () => undefined, + set: () => 0, + evict: () => {}, + clear: () => {}, + delete: key => { + if (key.partition === JSON.stringify(['srv', 'alice'])) return Promise.reject(new Error('own boom')); + deleted.push(key.partition ?? ''); + return undefined; + } + }; + const reported: unknown[] = []; + const cache = new ClientResponseCache(store, true, e => reported.push(e), 'alice'); + cache.setServerIdentity('srv'); + await cache.evictKey('resources/read', 'res://x'); + // Own-partition rejected → reported; shared-partition delete still ran. + expect((reported[0] as Error).message).toBe('own boom'); + expect(deleted).toEqual([JSON.stringify(['srv', ''])]); + }); + + it("write/read/evict address the list singletons consistently as params: '' on a non-normalizing custom store", async () => { + // A custom store that keys on the raw CacheKey without normalizing + // omitted/undefined `params` to '' (e.g. JSON.stringify, which drops + // undefined members). Every SDK→store call must therefore send the + // SAME params shape so write/read/evict address one backend key. + const entries = new Map(); + let stamp = 0; + const store: ResponseCacheStore = { + get: k => entries.get(JSON.stringify(k)), + set: (k, e) => (entries.set(JSON.stringify(k), { ...e, stamp: ++stamp }), stamp), + delete: k => void entries.delete(JSON.stringify(k)), + evict: () => {}, + clear: () => entries.clear() + }; + const cache = new ClientResponseCache(store, true); + await cache.write('tools/list', { tools: [TOOL_A] }, cache.captureGeneration('tools/list'), { + expiresAt: 1e9, + scope: 'private' + }); + // The read path finds the entry the write path stored. + expect((await cache.read('tools/list'))?.value).toEqual({ tools: [TOOL_A] }); + // The list_changed eviction path deletes the SAME backend key — gone. + await cache.evict('tools/list'); + expect(await cache.read('tools/list')).toBeUndefined(); + expect(entries.size).toBe(0); + }); + + it('a custom store whose set() rejects is routed to reportError and write still resolves', async () => { + const store: ResponseCacheStore = new InMemoryResponseCacheStore(); + store.set = () => Promise.reject(new Error('redis down')); + const reported: unknown[] = []; + const cache = new ClientResponseCache(store, true, e => reported.push(e)); + // The write resolves (cache bookkeeping never costs the caller a fetched + // result) and the failure is reported via the sink. + await expect(cache.write('tools/list', { tools: [TOOL_A] }, cache.captureGeneration('tools/list'))).resolves.toBeUndefined(); + expect(reported).toHaveLength(1); + expect((reported[0] as Error).message).toBe('redis down'); + }); + + it('toolDefinition: miss before any list, hit after, memoized index re-derives only on stamp change', async () => { + const store = new InMemoryResponseCacheStore(); + const cache = new ClientResponseCache(store, true); + expect(await cache.toolDefinition('a')).toBeUndefined(); + + store.set({ method: 'tools/list', partition: PRE }, { value: { tools: [TOOL_A, TOOL_B] } }); + const hit = await cache.toolDefinition('a'); + expect(hit?.name).toBe('a'); + // Same backing entry → identical reference (memoized index, not re-derived). + expect(await cache.toolDefinition('a')).toBe(hit); + + // A fresh write bumps the store stamp → the index re-derives (the new + // entry's tool instance is what comes back, not the memoized one). + store.set({ method: 'tools/list', partition: PRE }, { value: { tools: [{ ...TOOL_A }, { ...TOOL_B }] } }); + const hit2 = await cache.toolDefinition('a'); + expect(hit2?.name).toBe('a'); + expect(hit2).not.toBe(hit); + }); +}); + +interface Scripted { + clientTx: InMemoryTransport; + serverTx: InMemoryTransport; + listCount: () => number; + listParams: () => ({ cursor?: string; _meta?: unknown } | undefined)[]; + wireCount: (method: string) => number; +} + +interface ScriptOptions { + listHint?: { ttlMs?: number; cacheScope?: 'public' | 'private' }; + readHint?: { ttlMs?: number; cacheScope?: 'public' | 'private' }; + serverInfo?: { name: string; version: string }; +} + +async function scriptedModernServer(pages: Tool[][], opts: ScriptOptions = {}): Promise { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + let lists = 0; + const wireCounts = new Map(); + const params: ({ cursor?: string; _meta?: unknown } | undefined)[] = []; + serverTx.onmessage = m => { + const r = m as JSONRPCRequest; + if (r.id === undefined) return; + wireCounts.set(r.method, (wireCounts.get(r.method) ?? 0) + 1); + if (r.method === 'server/discover') { + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: { listChanged: true }, prompts: {}, resources: {} }, + serverInfo: opts.serverInfo ?? { name: 'scripted', version: '1.0.0' } + } + }); + } else if (r.method === 'tools/list') { + lists++; + params.push(r.params as { cursor?: string; _meta?: unknown } | undefined); + const cursor = (r.params as { cursor?: string } | undefined)?.cursor; + const idx = cursor === undefined ? 0 : Number(cursor); + const next = idx + 1 < pages.length ? String(idx + 1) : undefined; + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { + resultType: 'complete', + ttlMs: opts.listHint?.ttlMs ?? 0, + cacheScope: opts.listHint?.cacheScope ?? 'private', + tools: pages[idx] ?? [], + ...(next !== undefined && { nextCursor: next }) + } + }); + } else if (r.method === 'prompts/list' || r.method === 'resources/list' || r.method === 'resources/templates/list') { + const key = r.method === 'prompts/list' ? 'prompts' : r.method === 'resources/list' ? 'resources' : 'resourceTemplates'; + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { + resultType: 'complete', + ttlMs: opts.listHint?.ttlMs ?? 0, + cacheScope: opts.listHint?.cacheScope ?? 'private', + [key]: [] + } + }); + } else if (r.method === 'resources/read') { + const uri = (r.params as { uri: string }).uri; + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { + resultType: 'complete', + ...(opts.readHint?.ttlMs !== undefined && { ttlMs: opts.readHint.ttlMs }), + ...(opts.readHint?.cacheScope !== undefined && { cacheScope: opts.readHint.cacheScope }), + contents: [{ uri, mimeType: 'text/plain', text: `body:${uri}` }] + } + }); + } + }; + await serverTx.start(); + return { + clientTx, + serverTx, + listCount: () => lists, + listParams: () => params, + wireCount: m => wireCounts.get(m) ?? 0 + }; +} + +function modernClient(store?: InMemoryResponseCacheStore, extra?: { cachePartition?: string; defaultCacheTtlMs?: number }): Client { + return new Client( + { name: 'cache-client', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: MODERN } }, ...(store && { responseCacheStore: store }), ...extra } + ); +} + +/** Reach the private `_cache` collaborator for testing the derived view through the Client wiring. */ +const cacheOf = (client: Client): ClientResponseCache => (client as unknown as { _cache: ClientResponseCache })._cache; +const toolDef = (client: Client, name: string): Promise => cacheOf(client).toolDefinition(name); + +describe('Client response-cache substrate', () => { + it('listTools() with no cursor reads every page, writes one cache entry; listTools({cursor}) stays per-page and does not write', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, listCount } = await scriptedModernServer([[TOOL_A], [TOOL_B]]); + const client = modernClient(store); + await client.connect(clientTx); + + // Explicit cursor → one page, NO cache write (partial pages never go in). + const page = await client.listTools({ cursor: '1' }); + expect(page.tools.map(t => t.name)).toEqual(['b']); + expect(page.nextCursor).toBeUndefined(); + expect(store.get({ method: 'tools/list', partition: part() })).toBeUndefined(); + expect(listCount()).toBe(1); + + // No cursor → aggregates every page and writes one entry. + const { tools, nextCursor } = await client.listTools(); + expect(tools.map(t => t.name)).toEqual(['a', 'b']); + expect(nextCursor).toBeUndefined(); + expect(listCount()).toBe(3); + + const entry = store.get({ method: 'tools/list', partition: part() }); + expect((entry?.value as { tools: Tool[] }).tools.map(t => t.name)).toEqual(['a', 'b']); + }); + + it('the auto-aggregate path threads caller params (e.g. _meta trace context) into every page request', async () => { + const { clientTx, listParams } = await scriptedModernServer([[TOOL_A], [TOOL_B], [TOOL_A]]); + const client = modernClient(); + await client.connect(clientTx); + + const traceparent = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'; + const { tools } = await client.listTools({ _meta: { traceparent } }); + expect(tools.map(t => t.name)).toEqual(['a', 'b', 'a']); + // _listAllPages threads {...baseParams} on page 1 and {...baseParams, cursor} + // on every follow-up page, so the caller's _meta reaches every wire + // request the walk issues. + expect(listParams()).toHaveLength(3); + for (const p of listParams()) { + // The Protocol layer may auto-attach the modern-era envelope into + // _meta; assert the caller's key is present rather than exact-match. + expect((p?._meta as { traceparent?: string } | undefined)?.traceparent).toBe(traceparent); + } + expect(listParams().map(p => p?.cursor)).toEqual([undefined, '1', '2']); + }); + + it('mutating the returned aggregate does not corrupt the cache or its derived index', async () => { + const { clientTx } = await scriptedModernServer([[TOOL_A], [TOOL_B]]); + const client = modernClient(); + await client.connect(clientTx); + + const result = await client.listTools(); + expect((await toolDef(client, 'a'))?.name).toBe('a'); + // Common previously-harmless caller patterns. + result.tools.sort((x, y) => y.name.localeCompare(x.name)); + result.tools.length = 0; + // ClientResponseCache.write stored a structuredClone, so neither the + // backing entry nor the stamp-memoized name → Tool index moved. + expect((await toolDef(client, 'a'))?.name).toBe('a'); + expect((await toolDef(client, 'b'))?.name).toBe('b'); + }); + + it('the auto-aggregate path throws SdkError(ListPaginationExceeded) when listMaxPages is hit and does not write a partial entry', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx } = await scriptedModernServer([[TOOL_A], [TOOL_B], [TOOL_A]]); + const client = new Client( + { name: 'cache-client', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: MODERN } }, responseCacheStore: store, listMaxPages: 2 } + ); + await client.connect(clientTx); + + const error = await client.listTools().catch(e => e as SdkError); + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.ListPaginationExceeded); + expect((error as SdkError).message).toMatch(/exceeded listMaxPages \(2\); server pagination did not terminate/); + expect((error as SdkError).data).toEqual({ method: 'tools/list', listMaxPages: 2 }); + // Aggregate-then-write: the throw happens before the cache write, so nothing is cached. + expect(store.get({ method: 'tools/list', partition: part() })).toBeUndefined(); + // The per-page path is never capped. + const page = await client.listTools({ cursor: '2' }); + expect(page.tools.map(t => t.name)).toEqual(['a']); + }); + + it('listPrompts/listResources/listResourceTemplates auto-aggregate and write the response cache', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx } = await scriptedModernServer([[TOOL_A]]); + const client = modernClient(store); + await client.connect(clientTx); + + await client.listPrompts(); + await client.listResources(); + await client.listResourceTemplates(); + expect(store.get({ method: 'prompts/list', partition: part() })).toBeDefined(); + expect(store.get({ method: 'resources/list', partition: part() })).toBeDefined(); + expect(store.get({ method: 'resources/templates/list', partition: part() })).toBeDefined(); + }); + + it('toolDefinition through the Client wiring: miss before any list, hit after', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx } = await scriptedModernServer([[TOOL_A, TOOL_B]]); + const client = modernClient(store); + await client.connect(clientTx); + + expect(await toolDef(client, 'a')).toBeUndefined(); + await client.listTools(); + expect((await toolDef(client, 'a'))?.name).toBe('a'); + expect((await toolDef(client, 'b'))?.name).toBe('b'); + }); + + it('notifications/tools/list_changed evicts the tools/list entry (no refetch)', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, serverTx, listCount } = await scriptedModernServer([[TOOL_A]]); + const client = modernClient(store); + await client.connect(clientTx); + await client.listTools(); + expect(store.get({ method: 'tools/list', partition: part() })).toBeDefined(); + expect(await toolDef(client, 'a')).toBeDefined(); + + const before = listCount(); + await serverTx.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' } as JSONRPCMessage); + // Evicted, not refetched. + expect(store.get({ method: 'tools/list', partition: part() })).toBeUndefined(); + expect(await toolDef(client, 'a')).toBeUndefined(); + expect(listCount()).toBe(before); + }); + + it('notifications/resources/list_changed evicts both resources list verbs', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, serverTx } = await scriptedModernServer([[TOOL_A]]); + const client = modernClient(store); + await client.connect(clientTx); + await client.listResources(); + await client.listResourceTemplates(); + expect(store.get({ method: 'resources/list', partition: part() })).toBeDefined(); + expect(store.get({ method: 'resources/templates/list', partition: part() })).toBeDefined(); + + await serverTx.send({ jsonrpc: '2.0', method: 'notifications/resources/list_changed' } as JSONRPCMessage); + expect(store.get({ method: 'resources/list', partition: part() })).toBeUndefined(); + expect(store.get({ method: 'resources/templates/list', partition: part() })).toBeUndefined(); + }); + + it('_resetConnectionState leaves a user-supplied store untouched and drops the derived index', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx } = await scriptedModernServer([[TOOL_A]]); + const client = modernClient(store); + await client.connect(clientTx); + await client.listTools(); + expect(store.get({ method: 'tools/list', partition: part() })).toBeDefined(); + + await client.close(); + // A user-supplied store is NOT cleared on close/reconnect (defeats the + // only reason to supply one); the per-instance default IS cleared. + expect(store.get({ method: 'tools/list', partition: part() })).toBeDefined(); + // The derived index is connection-scoped regardless: it is dropped, and + // the next read re-derives from the (still-populated) store. + expect((cacheOf(client) as unknown as { _toolIndex?: unknown })._toolIndex).toBeUndefined(); + }); + + it('a notification whose method is an Object.prototype name does not abort dispatch', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, serverTx } = await scriptedModernServer([[TOOL_A]]); + const client = modernClient(store); + let fallback: string | undefined; + client.fallbackNotificationHandler = async n => { + fallback = n.method; + }; + let errored = false; + client.onerror = () => { + errored = true; + }; + await client.connect(clientTx); + + await serverTx.send({ jsonrpc: '2.0', method: 'constructor' } as JSONRPCMessage); + // The `Object.hasOwn` guard means `constructor` (an inherited prototype + // member) is NOT looked up as an eviction list and dispatch reaches the + // fallback handler without an error. + expect(errored).toBe(false); + expect(fallback).toBe('constructor'); + }); + + it('a custom store whose set() rejects is routed to onerror and the aggregate still returns', async () => { + const store = new InMemoryResponseCacheStore(); + (store as ResponseCacheStore).set = () => Promise.reject(new Error('redis down')); + const { clientTx } = await scriptedModernServer([[TOOL_A], [TOOL_B]]); + const client = modernClient(store); + const errors: Error[] = []; + client.onerror = e => errors.push(e); + await client.connect(clientTx); + + // Cache bookkeeping never costs the caller a result it already fetched + // (consistent with the eviction path): the store failure is reported + // via onerror and the fully-fetched aggregate still comes back. + const { tools } = await client.listTools(); + expect(tools.map(t => t.name)).toEqual(['a', 'b']); + expect(errors.map(e => e.message)).toContain('redis down'); + }); + + it('a custom store whose delete() throws on the list_changed eviction path is routed to onerror and dispatch still runs', async () => { + const store = new InMemoryResponseCacheStore(); + store.delete = () => { + throw new Error('boom'); + }; + const { clientTx, serverTx } = await scriptedModernServer([[TOOL_A]]); + const client = modernClient(store); + let dispatched = false; + client.setNotificationHandler('notifications/tools/list_changed', async () => { + dispatched = true; + }); + const errors: Error[] = []; + client.onerror = e => errors.push(e); + await client.connect(clientTx); + + await serverTx.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' } as JSONRPCMessage); + expect(errors.map(e => e.message)).toContain('boom'); + expect(dispatched).toBe(true); + }); +}); + +/** Freeze the cache's clock at `t` for deterministic freshness assertions. */ +const setNow = (client: Client, t: number): void => { + (cacheOf(client) as unknown as { _now: () => number })._now = () => t; +}; + +describe('Client honours cacheHints (SEP-2549)', () => { + it('listTools(): within TTL → no wire request; after TTL → refetch', async () => { + const { clientTx, listCount } = await scriptedModernServer([[TOOL_A, TOOL_B]], { listHint: { ttlMs: 30_000 } }); + const client = modernClient(); + await client.connect(clientTx); + setNow(client, 1_000_000); + + const first = await client.listTools(); + expect(first.tools.map(t => t.name)).toEqual(['a', 'b']); + expect(listCount()).toBe(1); + + // Within TTL → cache hit, no wire request. + setNow(client, 1_020_000); + const second = await client.listTools(); + expect(second.tools.map(t => t.name)).toEqual(['a', 'b']); + expect(listCount()).toBe(1); + // Clone-on-serve: hit is a fresh copy, not the stored object. + expect(second).not.toBe(first); + + // After TTL → stale, refetch. + setNow(client, 1_040_000); + await client.listTools(); + expect(listCount()).toBe(2); + }); + + it("cacheMode: 'refresh' always fetches and re-stores; 'bypass' fetches without read or write", async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, listCount } = await scriptedModernServer([[TOOL_A]], { listHint: { ttlMs: 60_000 } }); + const client = modernClient(store); + await client.connect(clientTx); + setNow(client, 1_000_000); + + await client.listTools(); + expect(listCount()).toBe(1); + const stamp1 = store.get({ method: 'tools/list', partition: part() })?.stamp; + + // 'refresh' ignores the still-fresh entry, fetches, and re-stores (new stamp). + await client.listTools(undefined, { cacheMode: 'refresh' }); + expect(listCount()).toBe(2); + const stamp2 = store.get({ method: 'tools/list', partition: part() })?.stamp; + expect(stamp2).toBeGreaterThan(stamp1!); + + // 'bypass' fetches but neither reads nor writes the cache. + await client.listTools(undefined, { cacheMode: 'bypass' }); + expect(listCount()).toBe(3); + expect(store.get({ method: 'tools/list', partition: part() })?.stamp).toBe(stamp2); + + // Default 'use' still serves the entry 'refresh' wrote. + await client.listTools(); + expect(listCount()).toBe(3); + }); + + it('listChanged eviction beats TTL: a still-fresh entry is dropped on the relevant notification', async () => { + const { clientTx, serverTx, listCount } = await scriptedModernServer([[TOOL_A]], { listHint: { ttlMs: 60_000 } }); + const client = modernClient(); + await client.connect(clientTx); + setNow(client, 1_000_000); + + await client.listTools(); + await client.listTools(); + expect(listCount()).toBe(1); + + // Relevant notification ⇒ entry immediately stale (spec): the next call refetches even within TTL. + await serverTx.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' } as JSONRPCMessage); + await client.listTools(); + expect(listCount()).toBe(2); + }); + + it('defaultCacheTtlMs: 0 (the default) means always-fetch but mirroring still works', async () => { + const { clientTx, listCount } = await scriptedModernServer([[TOOL_A]], { listHint: { ttlMs: 0 } }); + const client = modernClient(); + await client.connect(clientTx); + setNow(client, 1_000_000); + + await client.listTools(); + // ttlMs:0 ⇒ expiresAt === now ⇒ never served from cache. + await client.listTools(); + expect(listCount()).toBe(2); + // …but the entry IS stored (retain-for-schema), so the derived index works. + expect((await toolDef(client, 'a'))?.name).toBe('a'); + }); + + it('an explicit server ttlMs:0 is honoured as immediately stale (server hint wins over defaultCacheTtlMs)', async () => { + const { clientTx, listCount } = await scriptedModernServer([[TOOL_A]], { listHint: { ttlMs: 0 } }); + // defaultCacheTtlMs only applies when the result lacks ttlMs (e.g. a + // legacy-era response); a 2026 server's explicit 0 is the spec's + // "immediately stale" and is honoured as-is. + const client = modernClient(undefined, { defaultCacheTtlMs: 60_000 }); + await client.connect(clientTx); + setNow(client, 1_000_000); + + await client.listTools(); + await client.listTools(); + expect(listCount()).toBe(2); + }); + + it("same serverIdentity, different cachePartition: 'public' entries shared; 'private' entries isolated", async () => { + const store = new InMemoryResponseCacheStore(); + // Public scope: alice writes, bob (different cachePartition, SAME server) reads from the server's shared partition. + { + const a = await scriptedModernServer([[TOOL_A]], { listHint: { ttlMs: 60_000, cacheScope: 'public' } }); + const alice = modernClient(store, { cachePartition: 'alice' }); + await alice.connect(a.clientTx); + setNow(alice, 1_000_000); + await alice.listTools(); + expect(a.listCount()).toBe(1); + // Stored under the server's shared partition (`[serverIdentity, '']`). + expect(store.get({ method: 'tools/list', partition: part() })?.scope).toBe('public'); + expect(store.get({ method: 'tools/list', partition: part('alice') })).toBeUndefined(); + + const b = await scriptedModernServer([[TOOL_B]], { listHint: { ttlMs: 60_000, cacheScope: 'public' } }); + const bob = modernClient(store, { cachePartition: 'bob' }); + await bob.connect(b.clientTx); + setNow(bob, 1_000_000); + const { tools } = await bob.listTools(); + // Public-share across two clients of the SAME server on one store: bob is served alice's entry without a wire request. + expect(tools.map(t => t.name)).toEqual(['a']); + expect(b.listCount()).toBe(0); + } + store.clear(); + // Private scope: alice writes under her own partition; bob misses and fetches his own. + { + const a = await scriptedModernServer([[TOOL_A]], { listHint: { ttlMs: 60_000, cacheScope: 'private' } }); + const alice = modernClient(store, { cachePartition: 'alice' }); + await alice.connect(a.clientTx); + setNow(alice, 1_000_000); + await alice.listTools(); + expect(store.get({ method: 'tools/list', partition: part('alice') })?.scope).toBe('private'); + expect(store.get({ method: 'tools/list', partition: part() })).toBeUndefined(); + + const b = await scriptedModernServer([[TOOL_B]], { listHint: { ttlMs: 60_000, cacheScope: 'private' } }); + const bob = modernClient(store, { cachePartition: 'bob' }); + await bob.connect(b.clientTx); + setNow(bob, 1_000_000); + const { tools } = await bob.listTools(); + // Own-partition miss + shared-partition miss ⇒ bob fetches; alice's private entry never crosses. + expect(tools.map(t => t.name)).toEqual(['b']); + expect(b.listCount()).toBe(1); + // toolDefinition (mirroring source) reads from each client's own partition. + expect((await toolDef(alice, 'a'))?.name).toBe('a'); + expect((await toolDef(bob, 'b'))?.name).toBe('b'); + expect(await toolDef(bob, 'a')).toBeUndefined(); + } + }); + + it("different serverIdentity on a shared store: no cross-talk even for 'public' entries", async () => { + const store = new InMemoryResponseCacheStore(); + // Server X stamps public; client x writes under [x@1.0.0, '']. + const sx = await scriptedModernServer([[TOOL_A]], { + listHint: { ttlMs: 60_000, cacheScope: 'public' }, + serverInfo: { name: 'x', version: '1.0.0' } + }); + const x = modernClient(store); + await x.connect(sx.clientTx); + setNow(x, 1_000_000); + await x.listTools(); + expect(store.get({ method: 'tools/list', partition: part('', 'x@1.0.0') })?.scope).toBe('public'); + + // Server Y on the SAME store: y misses x's entry (different serverIdentity) and fetches its own. + const sy = await scriptedModernServer([[TOOL_B]], { + listHint: { ttlMs: 60_000, cacheScope: 'public' }, + serverInfo: { name: 'y', version: '1.0.0' } + }); + const y = modernClient(store); + await y.connect(sy.clientTx); + setNow(y, 1_000_000); + const { tools } = await y.listTools(); + expect(tools.map(t => t.name)).toEqual(['b']); + expect(sy.listCount()).toBe(1); + // Both entries co-exist under their own server namespaces. + expect(store.get({ method: 'tools/list', partition: part('', 'y@1.0.0') })?.scope).toBe('public'); + expect((await toolDef(x, 'a'))?.name).toBe('a'); + expect(await toolDef(y, 'a')).toBeUndefined(); + }); + + it("list_changed eviction is partition-scoped on a shared store: one server's notification leaves co-tenants' entries intact", async () => { + const store = new InMemoryResponseCacheStore(); + // Two clients on DIFFERENT servers share one store. Each has a fresh + // public tools/list entry under its own server-identity partition. + const sx = await scriptedModernServer([[TOOL_A]], { + listHint: { ttlMs: 60_000, cacheScope: 'public' }, + serverInfo: { name: 'x', version: '1.0.0' } + }); + const x = modernClient(store); + await x.connect(sx.clientTx); + setNow(x, 1_000_000); + await x.listTools(); + + const sy = await scriptedModernServer([[TOOL_B]], { + listHint: { ttlMs: 60_000, cacheScope: 'public' }, + serverInfo: { name: 'y', version: '1.0.0' } + }); + const y = modernClient(store); + await y.connect(sy.clientTx); + setNow(y, 1_000_000); + await y.listTools(); + expect(store.get({ method: 'tools/list', partition: part('', 'x@1.0.0') })).toBeDefined(); + expect(store.get({ method: 'tools/list', partition: part('', 'y@1.0.0') })).toBeDefined(); + + // Server X sends list_changed → only x's entry is dropped; y's + // co-tenant entry survives (evict() targets the connected server's + // two partition singletons, never the method-wide store.evict()). + await sx.serverTx.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' } as JSONRPCMessage); + expect(store.get({ method: 'tools/list', partition: part('', 'x@1.0.0') })).toBeUndefined(); + expect(store.get({ method: 'tools/list', partition: part('', 'y@1.0.0') })).toBeDefined(); + // y still cache-serves its own entry without a wire request. + await y.listTools(); + expect(sy.listCount()).toBe(1); + expect((await toolDef(y, 'b'))?.name).toBe('b'); + }); + + it("a malicious serverInfo cannot bleed into another server's principal slot (JSON encoding is collision-free)", async () => { + const store = new InMemoryResponseCacheStore(); + // The legitimate server B with principal 'victim'. Under naive + // `${name}@${version}|${cachePartition}` concat its private partition + // would be `realServer@1.0|victim`. + const sb = await scriptedModernServer([[TOOL_B]], { + listHint: { ttlMs: 60_000, cacheScope: 'private' }, + serverInfo: { name: 'realServer', version: '1.0' } + }); + const victim = modernClient(store, { cachePartition: 'victim' }); + await victim.connect(sb.clientTx); + setNow(victim, 1_000_000); + await victim.listTools(); + expect(sb.listCount()).toBe(1); + + // A malicious server A whose `name` embeds `@`/`|` to target B's + // naive-concat private slot. With JSON encoding the partition is + // `["realServer@1.0|victim@",""]` ≠ `["realServer@1.0","victim"]` — + // no collision possible regardless of what characters the + // server-controlled string carries. + const sa = await scriptedModernServer([[TOOL_A]], { + listHint: { ttlMs: 60_000, cacheScope: 'public' }, + serverInfo: { name: 'realServer@1.0|victim', version: '' } + }); + const attacker = modernClient(store); + await attacker.connect(sa.clientTx); + setNow(attacker, 1_000_000); + await attacker.listTools(); + + // B's private entry is unreachable from A (and vice versa): victim + // still cache-serves its own entry, attacker never observed it. + const again = await victim.listTools(); + expect(again.tools.map(t => t.name)).toEqual(['b']); + expect(sb.listCount()).toBe(1); + expect(await toolDef(attacker, 'b')).toBeUndefined(); + expect((await toolDef(victim, 'b'))?.name).toBe('b'); + }); + + it("a server flipping cacheScope private→public on a 'refresh' deletes the shadowing private-partition entry", async () => { + const store = new InMemoryResponseCacheStore(); + const pages: Tool[][] = [[TOOL_A]]; + const opts: ScriptOptions = { listHint: { ttlMs: 60_000, cacheScope: 'private' } }; + const a = await scriptedModernServer(pages, opts); + const alice = modernClient(store, { cachePartition: 'alice' }); + await alice.connect(a.clientTx); + setNow(alice, 1_000_000); + // Warm: private-scoped → stored under [serverIdentity, 'alice']. + await alice.listTools(); + expect(store.get({ method: 'tools/list', partition: part('alice') })?.scope).toBe('private'); + // Server flips the same key's scope to 'public' AND changes the body. + opts.listHint = { ttlMs: 60_000, cacheScope: 'public' }; + pages[0] = [TOOL_B]; + await alice.listTools(undefined, { cacheMode: 'refresh' }); + // Fresh body stored at the shared partition; the now-stale private + // entry is DELETED so it cannot shadow the public one on the + // own-first probe. + expect(store.get({ method: 'tools/list', partition: part() })?.scope).toBe('public'); + expect(store.get({ method: 'tools/list', partition: part('alice') })).toBeUndefined(); + // Next default-mode read serves the FRESH public body from cache (no wire). + const { tools } = await alice.listTools(); + expect(tools.map(t => t.name)).toEqual(['b']); + expect(a.listCount()).toBe(2); + }); + + it("the shared-partition fallback drops entries whose stored scope is not 'public' (misconfigured-co-tenant guard)", async () => { + const store = new InMemoryResponseCacheStore(); + // A misconfigured co-tenant (omits cachePartition, default '') writes a + // PRIVATE-scoped entry — which lands at the server's shared partition. + const a = await scriptedModernServer([[TOOL_A]], { listHint: { ttlMs: 60_000, cacheScope: 'private' } }); + const misconfigured = modernClient(store); + await misconfigured.connect(a.clientTx); + setNow(misconfigured, 1_000_000); + await misconfigured.listTools(); + expect(store.get({ method: 'tools/list', partition: part() })?.scope).toBe('private'); + + // A correctly-partitioned client probes own partition (miss), then the + // shared one — which holds the misconfigured client's PRIVATE entry. + // The `entry.scope === 'public'` gate drops it; bob fetches over the + // wire instead of leaking the private body. + const b = await scriptedModernServer([[TOOL_B]], { listHint: { ttlMs: 60_000, cacheScope: 'private' } }); + const bob = modernClient(store, { cachePartition: 'bob' }); + await bob.connect(b.clientTx); + setNow(bob, 1_000_000); + const { tools } = await bob.listTools(); + expect(tools.map(t => t.name)).toEqual(['b']); + expect(b.listCount()).toBe(1); + }); + + it('readResource(): keyed by uri, partitioned by scope, absent cacheScope is private; ttl≤0 is not stored', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, wireCount } = await scriptedModernServer([[TOOL_A]], { + readHint: { ttlMs: 60_000, cacheScope: 'private' } + }); + const client = modernClient(store, { cachePartition: 'alice' }); + await client.connect(clientTx); + setNow(client, 1_000_000); + + const r1 = await client.readResource({ uri: 'res://one' }); + expect(r1.contents[0]).toMatchObject({ text: 'body:res://one' }); + expect(wireCount('resources/read')).toBe(1); + // Within TTL → cache hit on the same uri. + const r2 = await client.readResource({ uri: 'res://one' }); + expect(r2.contents[0]).toMatchObject({ text: 'body:res://one' }); + expect(wireCount('resources/read')).toBe(1); + // Different uri → distinct key, fetch. + await client.readResource({ uri: 'res://two' }); + expect(wireCount('resources/read')).toBe(2); + // 'refresh' on the first uri → fetch. + await client.readResource({ uri: 'res://one' }, { cacheMode: 'refresh' }); + expect(wireCount('resources/read')).toBe(3); + // Stored under alice's partition only (private). + expect(store.get({ method: 'resources/read', params: 'res://one', partition: part('alice') })).toBeDefined(); + expect(store.get({ method: 'resources/read', params: 'res://one', partition: part() })).toBeUndefined(); + + // bob on a shared store cannot read alice's private resource body. + const b = await scriptedModernServer([[TOOL_A]], { readHint: { ttlMs: 60_000, cacheScope: 'private' } }); + const bob = modernClient(store, { cachePartition: 'bob' }); + await bob.connect(b.clientTx); + setNow(bob, 1_000_000); + await bob.readResource({ uri: 'res://one' }); + expect(b.wireCount('resources/read')).toBe(1); + }); + + // The wire codec rejects a 2026-07-28 cacheable result without `cacheScope` + // (it is a required field), so the absent-scope path is unreachable through + // `request()`. The `_freshness` private-default is defence-in-depth only; + // the partition test above asserts the explicit-`'private'` storage slot. + + it('readResource(): ttl≤0 is not stored (unbounded URI keyspace) but the result still returns', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, wireCount } = await scriptedModernServer([[TOOL_A]], { readHint: { ttlMs: 0, cacheScope: 'public' } }); + const client = modernClient(store); + await client.connect(clientTx); + setNow(client, 1_000_000); + const r = await client.readResource({ uri: 'res://x' }); + expect(r.contents[0]).toMatchObject({ text: 'body:res://x' }); + expect(store.get({ method: 'resources/read', params: 'res://x', partition: part() })).toBeUndefined(); + await client.readResource({ uri: 'res://x' }); + expect(wireCount('resources/read')).toBe(2); + }); + + it('readResource(): 600 distinct ttl=0 URIs issue zero store.delete() calls (evictKey skipped on a cold default-mode miss)', async () => { + // Regression: every ttl≤0 default-mode read used to call + // `evictKey('resources/read', uri)` unconditionally, which issued 1–2 + // `store.delete()` calls against a cold key — wasted round trips on + // an async store across a ttl≤0 working set. The evict is now skipped + // when `_serveFromCache` already proved nothing fresh is held. + const store = new InMemoryResponseCacheStore(); + let deletes = 0; + const realDelete = store.delete.bind(store); + (store as ResponseCacheStore).delete = key => { + deletes++; + return realDelete(key); + }; + const { clientTx } = await scriptedModernServer([[TOOL_A]], { readHint: { ttlMs: 0, cacheScope: 'public' } }); + const client = modernClient(store); + await client.connect(clientTx); + setNow(client, 1_000_000); + + for (let i = 0; i < 600; i++) await client.readResource({ uri: `res://cold/${i}` }); + expect(store.size).toBe(0); + // No store.delete() issued for any of the 600 cold-miss ttl≤0 reads. + expect(deletes).toBe(0); + // `captureGeneration` recorded one entry per read URI (the in-flight + // guard's presence record); none was bumped — `evictKey` was never + // reached. The map is bounded by keys the CLIENT chose to read. + const gen = (cacheOf(client) as unknown as { _evictionGeneration: Map })._evictionGeneration; + expect(gen.size).toBe(600); + expect([...gen.values()].every(v => v === 0)).toBe(true); + }); + + it('600 distinct-URI notifications/resources/updated with no prior readResource do not grow the eviction-generation map; a read URI is still guarded', async () => { + // Regression: `evictKey` used to bump (and therefore record) the + // per-URI generation unconditionally, so a server streaming + // `resources/updated` for distinct URIs grew `_evictionGeneration` + // without bound — server-controlled heap growth. `evictKey` now only + // bumps a key the client has captured. + const store = new InMemoryResponseCacheStore(); + const { clientTx, serverTx } = await scriptedModernServer([[TOOL_A]], { + readHint: { ttlMs: 60_000, cacheScope: 'private' } + }); + const client = modernClient(store); + await client.connect(clientTx); + setNow(client, 1_000_000); + const gen = (cacheOf(client) as unknown as { _evictionGeneration: Map })._evictionGeneration; + + for (let i = 0; i < 600; i++) { + await serverTx.send({ + jsonrpc: '2.0', + method: 'notifications/resources/updated', + params: { uri: `res://never-read/${i}` } + } as JSONRPCMessage); + } + expect(gen.size).toBe(0); + + // A URI the client HAS read is recorded by captureGeneration; an + // `updated` for it bumps (the in-flight guard still works). + await client.readResource({ uri: 'res://hot' }); + expect(cacheOf(client).captureGeneration('resources/read', 'res://hot')).toBe(0); + await serverTx.send({ + jsonrpc: '2.0', + method: 'notifications/resources/updated', + params: { uri: 'res://hot' } + } as JSONRPCMessage); + expect(cacheOf(client).captureGeneration('resources/read', 'res://hot')).toBe(1); + expect(gen.size).toBe(1); + }); + + it("readResource(): a 'refresh' that returns ttl≤0 evicts the previously-warm entry; the next default-mode read fetches fresh", async () => { + const store = new InMemoryResponseCacheStore(); + const opts: ScriptOptions = { readHint: { ttlMs: 60_000, cacheScope: 'private' } }; + const { clientTx, wireCount } = await scriptedModernServer([[TOOL_A]], opts); + const client = modernClient(store, { cachePartition: 'alice' }); + await client.connect(clientTx); + setNow(client, 1_000_000); + + // Warm: ttl=60s. + await client.readResource({ uri: 'res://x' }); + expect(wireCount('resources/read')).toBe(1); + await client.readResource({ uri: 'res://x' }); + expect(wireCount('resources/read')).toBe(1); + expect(store.get({ method: 'resources/read', params: 'res://x', partition: part('alice') })).toBeDefined(); + + // Server flips to ttl=0; a 'refresh' fetch returns ttl≤0 → the held + // positive-TTL entry is evicted, not left stale-but-fresh. + opts.readHint = { ttlMs: 0, cacheScope: 'private' }; + await client.readResource({ uri: 'res://x' }, { cacheMode: 'refresh' }); + expect(wireCount('resources/read')).toBe(2); + expect(store.get({ method: 'resources/read', params: 'res://x', partition: part('alice') })).toBeUndefined(); + + // The next default-mode read fetches fresh (the entry was evicted). + await client.readResource({ uri: 'res://x' }); + expect(wireCount('resources/read')).toBe(3); + }); + + it('a pre-aborted signal on a warm-cache hit rejects with SdkError(RequestTimeout) — the abort is not swallowed by the cache serve', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, listCount } = await scriptedModernServer([[TOOL_A]], { listHint: { ttlMs: 60_000, cacheScope: 'public' } }); + const client = modernClient(store); + await client.connect(clientTx); + setNow(client, 1_000_000); + + await client.listTools(); + expect(listCount()).toBe(1); + // Warm — a plain second call would be cache-served. With a pre-aborted + // signal it must reject the same way the wire path would. + const ac = new AbortController(); + ac.abort('user cancelled'); + const error = await client.listTools(undefined, { signal: ac.signal }).catch(e => e as SdkError); + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.RequestTimeout); + expect((error as SdkError).message).toContain('user cancelled'); + // The aborted call did not reach the wire. + expect(listCount()).toBe(1); + }); + + it('notifications/resources/updated evicts the cached resources/read entry for that URI from both partitions', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, serverTx, wireCount } = await scriptedModernServer([[TOOL_A]], { + readHint: { ttlMs: 60_000, cacheScope: 'private' } + }); + const client = modernClient(store, { cachePartition: 'alice' }); + await client.connect(clientTx); + setNow(client, 1_000_000); + + await client.readResource({ uri: 'res://one' }); + await client.readResource({ uri: 'res://two' }); + expect(wireCount('resources/read')).toBe(2); + // Within TTL → cache hit. + await client.readResource({ uri: 'res://one' }); + expect(wireCount('resources/read')).toBe(2); + + // Subscribe → updated → re-read flow: the per-URI eviction drops the + // cached body from BOTH partitions; the next read for THAT uri + // refetches even within TTL; the sibling uri is untouched. + await serverTx.send({ jsonrpc: '2.0', method: 'notifications/resources/updated', params: { uri: 'res://one' } } as JSONRPCMessage); + expect(store.get({ method: 'resources/read', params: 'res://one', partition: part('alice') })).toBeUndefined(); + await client.readResource({ uri: 'res://one' }); + expect(wireCount('resources/read')).toBe(3); + await client.readResource({ uri: 'res://two' }); + expect(wireCount('resources/read')).toBe(3); + + // A `resources/updated` without a string `uri` is a no-op (matches the + // mcp.d guard). + await serverTx.send({ jsonrpc: '2.0', method: 'notifications/resources/updated', params: {} } as JSONRPCMessage); + await client.readResource({ uri: 'res://one' }); + expect(wireCount('resources/read')).toBe(3); + }); + + it('ttlMs is clamped at 24h (MAX_CACHE_TTL_MS) so a server cannot pin an entry indefinitely', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, listCount } = await scriptedModernServer([[TOOL_A]], { + listHint: { ttlMs: Number.MAX_SAFE_INTEGER, cacheScope: 'public' } + }); + const client = modernClient(store); + await client.connect(clientTx); + setNow(client, 1_000_000); + await client.listTools(); + const entry = store.get({ method: 'tools/list', partition: part() }); + // expiresAt = now + min(ttlMs, 24h) + expect(entry?.expiresAt).toBe(1_000_000 + 86_400_000); + // Just under 24h → still served from cache. + setNow(client, 1_000_000 + 86_400_000 - 1); + await client.listTools(); + expect(listCount()).toBe(1); + // Past 24h → refetch. + setNow(client, 1_000_000 + 86_400_000 + 1); + await client.listTools(); + expect(listCount()).toBe(2); + }); + + it('the default in-memory store is bounded: 600 distinct readResource URIs cap at 512 with oldest-first eviction', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx } = await scriptedModernServer([[TOOL_A]], { readHint: { ttlMs: 60_000, cacheScope: 'public' } }); + const client = modernClient(store); + await client.connect(clientTx); + setNow(client, 1_000_000); + + for (let i = 0; i < 600; i++) await client.readResource({ uri: `res://${i}` }); + expect(store.size).toBe(512); + // The first 88 URIs (oldest insertions) were evicted; the tail survived. + expect(store.get({ method: 'resources/read', params: 'res://0', partition: part() })).toBeUndefined(); + expect(store.get({ method: 'resources/read', params: 'res://87', partition: part() })).toBeUndefined(); + expect(store.get({ method: 'resources/read', params: 'res://88', partition: part() })).toBeDefined(); + expect(store.get({ method: 'resources/read', params: 'res://599', partition: part() })).toBeDefined(); + }); + + it('the maxEntries cap never evicts the tools/list singleton: 600 readResource URIs leave the derived index intact', async () => { + const store = new InMemoryResponseCacheStore(); + const { clientTx, listCount } = await scriptedModernServer([[TOOL_A]], { + listHint: { ttlMs: 60_000, cacheScope: 'public' }, + readHint: { ttlMs: 60_000, cacheScope: 'public' } + }); + const client = modernClient(store); + await client.connect(clientTx); + setNow(client, 1_000_000); + + await client.listTools(); + expect((await toolDef(client, 'a'))?.name).toBe('a'); + for (let i = 0; i < 600; i++) await client.readResource({ uri: `res://${i}` }); + // 512 capped resources/read entries + the exempt tools/list singleton. + expect(store.size).toBe(513); + // The list singleton survived the FIFO churn → derived index still hits; + // a fresh listTools() within TTL is still cache-served. + expect(store.get({ method: 'tools/list', partition: part() })).toBeDefined(); + expect((await toolDef(client, 'a'))?.name).toBe('a'); + await client.listTools(); + expect(listCount()).toBe(1); + }); + + it('an in-flight readResource() does not re-cache a stale body when resources/updated for that URI lands mid-request', async () => { + const store = new InMemoryResponseCacheStore(); + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + let reads = 0; + let pendingId: string | number | undefined; + serverTx.onmessage = m => { + const r = m as JSONRPCRequest; + if (r.id === undefined) return; + if (r.method === 'server/discover') { + void serverTx.send({ + jsonrpc: '2.0', + id: r.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { resources: {} }, + serverInfo: { name: 'scripted', version: '1.0.0' } + } + }); + } else if (r.method === 'resources/read') { + reads++; + pendingId = r.id; // defer — the test drives the response + } + }; + await serverTx.start(); + const client = modernClient(store, { cachePartition: 'alice' }); + await client.connect(clientTx); + setNow(client, 1_000_000); + + const respond = (text: string): void => + void serverTx.send({ + jsonrpc: '2.0', + id: pendingId!, + result: { + resultType: 'complete', + ttlMs: 60_000, + cacheScope: 'private', + contents: [{ uri: 'res://x', mimeType: 'text/plain', text }] + } + }); + + // Kick off the read; let the request reach the server. + const inflight = client.readResource({ uri: 'res://x' }); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(reads).toBe(1); + // resources/updated for THIS uri lands while the read is in flight → + // bumps the per-URI generation; the eventual write is suppressed. + await serverTx.send({ jsonrpc: '2.0', method: 'notifications/resources/updated', params: { uri: 'res://x' } } as JSONRPCMessage); + respond('stale'); + const r1 = await inflight; + expect(r1.contents[0]).toMatchObject({ text: 'stale' }); + expect(store.get({ method: 'resources/read', params: 'res://x', partition: part('alice') })).toBeUndefined(); + + // The next read for the same URI refetches (no stale cache hit) and + // its write goes through (fresh capture). + const next = client.readResource({ uri: 'res://x' }); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(reads).toBe(2); + respond('fresh'); + expect((await next).contents[0]).toMatchObject({ text: 'fresh' }); + expect(store.get({ method: 'resources/read', params: 'res://x', partition: part('alice') })).toBeDefined(); + }); + + it('a custom store whose get() rejects degrades to a miss; the request still reaches the wire', async () => { + const store = new InMemoryResponseCacheStore(); + (store as ResponseCacheStore).get = () => Promise.reject(new Error('redis down')); + const { clientTx, listCount } = await scriptedModernServer([[TOOL_A]], { listHint: { ttlMs: 60_000 } }); + const client = modernClient(store); + const errors: Error[] = []; + client.onerror = e => errors.push(e); + await client.connect(clientTx); + setNow(client, 1_000_000); + + const { tools } = await client.listTools(); + expect(tools.map(t => t.name)).toEqual(['a']); + expect(listCount()).toBe(1); + expect(errors.map(e => e.message)).toContain('redis down'); + }); +}); diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index 4ccbb91e5f..1be1f03c70 100644 --- a/packages/client/test/client/sse.test.ts +++ b/packages/client/test/client/sse.test.ts @@ -45,12 +45,14 @@ describe('SSEClientTransport', () => { res.writeHead(200, { 'Content-Type': 'application/json' }); + // RFC 8414 §3.3: issuer must match the URL the metadata was fetched from. + const self = `http://${req.headers.host}`; res.end( JSON.stringify({ - issuer: 'https://auth.example.com', - authorization_endpoint: 'https://auth.example.com/authorize', - token_endpoint: 'https://auth.example.com/token', - registration_endpoint: 'https://auth.example.com/register', + issuer: self, + authorization_endpoint: `${self}/authorize`, + token_endpoint: `${self}/token`, + registration_endpoint: `${self}/register`, response_types_supported: ['code'], code_challenge_methods_supported: ['S256'] }) @@ -778,11 +780,14 @@ describe('SSEClientTransport', () => { await transport.start(); - expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({ - access_token: 'new-token', - token_type: 'Bearer', - refresh_token: 'new-refresh-token' - }); + expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith( + expect.objectContaining({ + access_token: 'new-token', + token_type: 'Bearer', + refresh_token: 'new-refresh-token' + }), + expect.anything() + ); expect(connectionAttempts).toBe(1); expect(lastServerRequest.headers.authorization).toBe('Bearer new-token'); }); @@ -931,11 +936,14 @@ describe('SSEClientTransport', () => { await transport.send(message); - expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({ - access_token: 'new-token', - token_type: 'Bearer', - refresh_token: 'new-refresh-token' - }); + expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith( + expect.objectContaining({ + access_token: 'new-token', + token_type: 'Bearer', + refresh_token: 'new-refresh-token' + }), + expect.anything() + ); expect(postAttempts).toBe(1); expect(lastServerRequest.headers.authorization).toBe('Bearer new-token'); }); @@ -1018,7 +1026,7 @@ describe('SSEClientTransport', () => { expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled(); }); - it('invalidates all credentials on OAuthErrorCode.InvalidClient during token refresh', async () => { + it('invalidates client+tokens (not discovery) on OAuthErrorCode.InvalidClient during token refresh', async () => { // Mock tokens() to return token with refresh token mockAuthProvider.tokens.mockResolvedValue({ access_token: 'expired-token', @@ -1067,10 +1075,14 @@ describe('SSEClientTransport', () => { }); await expect(() => transport.start()).rejects.toMatchObject(expectedError); - expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all'); + // SEP-2352: 'client'+'tokens' (not 'all') so discoveryState survives for the + // callback-leg gate on retry. + expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('client'); + expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens'); + expect(mockAuthProvider.invalidateCredentials).not.toHaveBeenCalledWith('all'); }); - it('invalidates all credentials on OAuthErrorCode.UnauthorizedClient during token refresh', async () => { + it('invalidates client+tokens (not discovery) on OAuthErrorCode.UnauthorizedClient during token refresh', async () => { // Mock tokens() to return token with refresh token mockAuthProvider.tokens.mockResolvedValue({ access_token: 'expired-token', @@ -1118,7 +1130,11 @@ describe('SSEClientTransport', () => { }); await expect(() => transport.start()).rejects.toMatchObject(expectedError); - expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all'); + // SEP-2352: 'client'+'tokens' (not 'all') so discoveryState survives for the + // callback-leg gate on retry. + expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('client'); + expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens'); + expect(mockAuthProvider.invalidateCredentials).not.toHaveBeenCalledWith('all'); }); it('invalidates tokens on OAuthErrorCode.InvalidGrant during token refresh', async () => { @@ -1517,12 +1533,15 @@ describe('SSEClientTransport', () => { expect(tokenCalls.length).toBeGreaterThan(0); // Verify tokens were saved - expect(authProviderWithCode.saveTokens).toHaveBeenCalledWith({ - access_token: 'new-access-token', - token_type: 'Bearer', - expires_in: 3600, - refresh_token: 'new-refresh-token' - }); + expect(authProviderWithCode.saveTokens).toHaveBeenCalledWith( + expect.objectContaining({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }), + expect.anything() + ); // Global fetch should never have been called expect(globalFetchSpy).not.toHaveBeenCalled(); 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/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 6542302c9d..866ca52be9 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -404,6 +404,15 @@ describe('StreamableHTTPClientTransport', () => { ).toBe(true); }); + it('declares hasPerRequestStream so the protocol layer routes 2026-era cancellation to stream-close', () => { + // Spec basic/patterns/cancellation §Transport-Specific (2026-07-28): + // closing the per-request SSE stream IS the cancel signal on + // Streamable HTTP. Protocol.request() keys on this flag (plus the + // negotiated era) to abort `requestSignal` instead of POSTing + // `notifications/cancelled`. + expect(transport.hasPerRequestStream).toBe(true); + }); + it('should support custom reconnection options', () => { // Create a transport with custom reconnection options transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { @@ -822,11 +831,14 @@ describe('StreamableHTTPClientTransport', () => { // Verify fetch was called twice expect(fetchMock).toHaveBeenCalledTimes(2); - // Verify auth was called with the new scope + // Verify auth was called with the union scope (no prior scope → just the + // challenged scope) and forced fresh authorization (no prior token scope + // means the union is a strict superset of the empty grant). expect(authSpy).toHaveBeenCalledWith( mockAuthProvider, expect.objectContaining({ scope: 'new_scope', + forceReauthorization: true, resourceMetadataUrl: new URL('http://example.com/resource') }) ); @@ -834,7 +846,7 @@ describe('StreamableHTTPClientTransport', () => { authSpy.mockRestore(); }); - it('prevents infinite upscoping on repeated 403', async () => { + it('caps step-up retries per send (bounded counter)', async () => { const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test', @@ -859,19 +871,94 @@ describe('StreamableHTTPClientTransport', () => { const authSpy = vi.spyOn(authModule as typeof import('../../src/client/auth.js'), 'auth'); authSpy.mockResolvedValue('AUTHORIZED'); - // First send: should trigger upscoping - await expect(transport.send(message)).rejects.toThrow('Server returned 403 after trying upscoping'); + // First send: one step-up retry (default cap = 1), then fails. + await expect(transport.send(message)).rejects.toThrow(/403 insufficient_scope after step-up re-authorization/); expect(fetchMock).toHaveBeenCalledTimes(2); // Initial call + one retry after auth expect(authSpy).toHaveBeenCalledTimes(1); // Auth called once - // Second send: should fail immediately without re-calling auth + // Second send: counter is per-send-chain, not transport-wide — a fresh + // send tries step-up once again (cross-request tracking is host + // responsibility). fetchMock.mockClear(); authSpy.mockClear(); - await expect(transport.send(message)).rejects.toThrow('Server returned 403 after trying upscoping'); + await expect(transport.send(message)).rejects.toThrow(/403 insufficient_scope after step-up re-authorization/); - expect(fetchMock).toHaveBeenCalledTimes(1); // Only one fetch call - expect(authSpy).not.toHaveBeenCalled(); // Auth not called again + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(authSpy).toHaveBeenCalledTimes(1); + + authSpy.mockRestore(); + }); + + it('step-up scope is the union of transport-tracked, token-granted, and challenged scopes', async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: mockAuthProvider, + maxStepUpRetries: 2 + }); + mockAuthProvider.tokens.mockResolvedValue({ access_token: 't', token_type: 'Bearer', scope: 'a b' }); + + const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test', params: {}, id: 'test-id' }; + const fetchMock = globalThis.fetch as Mock; + fetchMock + .mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Headers({ 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="b c"' }), + text: () => Promise.resolve('') + }) + .mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Headers({ 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="d"' }), + text: () => Promise.resolve('') + }) + .mockResolvedValueOnce({ ok: true, status: 202, headers: new Headers() }); + + const authModule = await import('../../src/client/auth.js'); + const authSpy = vi.spyOn(authModule, 'auth'); + authSpy.mockResolvedValue('AUTHORIZED'); + + await transport.send(message); + + expect(authSpy).toHaveBeenCalledTimes(2); + // First step-up: union(undefined, token 'a b', challenge 'b c') = 'a b c' + expect(authSpy.mock.calls[0]![1].scope?.split(' ').sort()).toEqual(['a', 'b', 'c']); + // Second step-up: union(tracked 'a b c', token 'a b', challenge 'd') = 'a b c d' + expect(authSpy.mock.calls[1]![1].scope?.split(' ').sort()).toEqual(['a', 'b', 'c', 'd']); + + authSpy.mockRestore(); + }); + + it("throws InsufficientScopeError on 403 when onInsufficientScope is 'throw'", async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: mockAuthProvider, + onInsufficientScope: 'throw' + }); + const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test', params: {}, id: 'test-id' }; + + (globalThis.fetch as Mock).mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Headers({ + 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="files:write", error_description="needs write"' + }), + text: () => Promise.resolve('Insufficient scope') + }); + + const authModule = await import('../../src/client/auth.js'); + const authSpy = vi.spyOn(authModule, 'auth'); + const { InsufficientScopeError } = await import('../../src/client/authErrors.js'); + + const sendPromise = transport.send(message); + await expect(sendPromise).rejects.toBeInstanceOf(InsufficientScopeError); + await expect(sendPromise).rejects.toMatchObject({ + requiredScope: 'files:write', + errorDescription: 'needs write' + }); + expect(authSpy).not.toHaveBeenCalled(); authSpy.mockRestore(); }); @@ -1102,6 +1189,407 @@ describe('StreamableHTTPClientTransport', () => { expect(fetchMock.mock.calls[0]![1]?.method).toBe('POST'); }); + it('per-request requestSignal abort: no onerror, no reconnect (McpSubscription.close())', async () => { + // ARRANGE — a POST stream that has been primed with an SSE event id + // (server-side resumability), so without the per-request abort + // guard the transport WOULD schedule a GET+Last-Event-ID reconnect. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 10, + maxRetries: 1, + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1 + } + }); + const errorSpy = vi.fn(); + transport.onerror = errorSpy; + + let streamController!: ReadableStreamDefaultController; + const primedStream = new ReadableStream({ + start(controller) { + streamController = controller; + // Priming event with an id — would arm POST-stream resumability. + controller.enqueue(new TextEncoder().encode('id: ev-1\ndata: \n\n')); + } + }); + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockImplementationOnce((_url, init: RequestInit) => { + // Propagate abort to the stream the way fetch does. + init.signal?.addEventListener('abort', () => streamController.error(init.signal?.reason), { once: true }); + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: primedStream + }); + }); + + const requestAbort = new AbortController(); + await transport.start(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen-1', params: {} }, + { requestSignal: requestAbort.signal } + ); + await vi.advanceTimersByTimeAsync(5); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // ACT — McpSubscription.close() aborts the per-request signal. + requestAbort.abort(); + await vi.advanceTimersByTimeAsync(50); + + // ASSERT — intentional per-request abort: no onerror, no reconnect. + expect(errorSpy).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('onRequestStreamEnd fires when the per-request POST stream ends gracefully without reconnecting', async () => { + // ARRANGE — a POST stream with NO priming event id (so the + // graceful-close path does NOT schedule a reconnect): the + // per-request stream simply ends. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + let streamController!: ReadableStreamDefaultController; + const unprimedStream = new ReadableStream({ + start(controller) { + streamController = controller; + // An ack frame with no SSE event id — does NOT arm POST-stream resumability. + controller.enqueue( + new TextEncoder().encode( + 'data: {"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{}}\n\n' + ) + ); + } + }); + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: unprimedStream + }) + ); + + const requestAbort = new AbortController(); + const onStreamEnd = vi.fn(); + await transport.start(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} }, + { requestSignal: requestAbort.signal, onRequestStreamEnd: onStreamEnd } + ); + await vi.advanceTimersByTimeAsync(5); + expect(onStreamEnd).not.toHaveBeenCalled(); + + // ACT — server gracefully closes the SSE stream. + streamController.close(); + await vi.advanceTimersByTimeAsync(5); + + // ASSERT — non-deliberate stream end without reconnecting: + // onRequestStreamEnd fired exactly once; no further fetches. + expect(onStreamEnd).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('onRequestStreamEnd does NOT fire on a deliberate per-request abort', async () => { + // Same shape as the no-onerror/no-reconnect test, but assert the + // stream-end callback is NEVER invoked when `requestSignal` was the + // abort source. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + let streamController!: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(controller) { + streamController = controller; + } + }); + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockImplementationOnce((_url, init: RequestInit) => { + init.signal?.addEventListener('abort', () => streamController.error(init.signal?.reason), { once: true }); + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: stream + }); + }); + + const requestAbort = new AbortController(); + const onStreamEnd = vi.fn(); + await transport.start(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} }, + { requestSignal: requestAbort.signal, onRequestStreamEnd: onStreamEnd } + ); + await vi.advanceTimersByTimeAsync(5); + + // ACT — deliberate per-request abort. + requestAbort.abort(); + await vi.advanceTimersByTimeAsync(50); + + // ASSERT — deliberate abort: onRequestStreamEnd never fires. + expect(onStreamEnd).not.toHaveBeenCalled(); + }); + + it('onRequestStreamEnd fires when reconnection attempts are exhausted (maxRetries reached)', async () => { + // ARRANGE — a primed POST stream (so a non-deliberate close + // schedules a GET resume); every GET resume fails; maxRetries 1 + // means the second schedule hits the exhausted branch. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 5, + maxRetries: 1, + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1 + } + }); + const errorSpy = vi.fn(); + transport.onerror = errorSpy; + + let streamController!: ReadableStreamDefaultController; + const primedStream = new ReadableStream({ + start(controller) { + streamController = controller; + controller.enqueue(new TextEncoder().encode('id: ev-1\ndata: \n\n')); + } + }); + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: primedStream + }); + // The GET resume fails with a 5xx → reconnect catch reschedules → exhausted. + fetchMock.mockResolvedValue({ ok: false, status: 503, statusText: 'unavailable', headers: new Headers() }); + + const onStreamEnd = vi.fn(); + await transport.start(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} }, + { requestSignal: new AbortController().signal, onRequestStreamEnd: onStreamEnd } + ); + await vi.advanceTimersByTimeAsync(5); + expect(onStreamEnd).not.toHaveBeenCalled(); + + // ACT — server closes the primed POST stream non-deliberately. + streamController.close(); + await vi.advanceTimersByTimeAsync(100); + + // ASSERT — exhausted: onRequestStreamEnd fired exactly once (the + // max-retries branch); the exhausted onerror surfaced. + expect(onStreamEnd).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('Maximum reconnection attempts') }) + ); + }); + + it('onRequestStreamEnd fires when the per-request POST stream ERRORS without reconnecting', async () => { + // ARRANGE — a POST stream with NO priming event id; the body + // errors (network drop). The error-branch `else` (no reconnect, + // not intentional-abort) must fire onRequestStreamEnd. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + const failingStream = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + 'data: {"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{}}\n\n' + ) + ); + queueMicrotask(() => controller.error(new Error('network drop'))); + } + }); + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: failingStream + }); + + const onStreamEnd = vi.fn(); + await transport.start(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} }, + { requestSignal: new AbortController().signal, onRequestStreamEnd: onStreamEnd } + ); + await vi.advanceTimersByTimeAsync(50); + + // ASSERT — error-branch fired exactly once; no reconnection + // attempted (POST stream wasn't primed). + expect(onStreamEnd).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('onRequestStreamEnd does NOT fire on transport.close()', async () => { + // The transport-wide abort is the OTHER deliberate teardown + // (`isIntentionalAbort()` checks both signals): a per-request + // stream-end callback must not fire when close() tore the stream + // down — `_onclose` is the settle path for that. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + let streamController!: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(controller) { + streamController = controller; + controller.enqueue(new TextEncoder().encode('id: ev-1\ndata: \n\n')); + } + }); + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockImplementationOnce((_url, init: RequestInit) => { + init.signal?.addEventListener('abort', () => streamController.error(init.signal?.reason), { once: true }); + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: stream + }); + }); + + const onStreamEnd = vi.fn(); + await transport.start(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} }, + { requestSignal: new AbortController().signal, onRequestStreamEnd: onStreamEnd } + ); + await vi.advanceTimersByTimeAsync(5); + + // ACT — transport-wide close. + await transport.close(); + await vi.advanceTimersByTimeAsync(50); + + // ASSERT — deliberate transport close: onRequestStreamEnd never fires. + expect(onStreamEnd).not.toHaveBeenCalled(); + }); + + it('onRequestStreamEnd fires when a primed POST→GET resume hits 405 (non-resumable terminal)', async () => { + // R1 regression: against a server that stamps SSE event ids on the + // listen POST stream but returns 405 on the GET resume, + // `_startOrAuthSse` resolved without a stream and nothing fired — + // the subscription dead-ended silently. The 405 is now a terminal + // per-request stream-end. ALSO asserts the GET resume carried the + // per-request `requestSignal` (the close-after-reconnect path). + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 5, + maxRetries: 3, + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1 + } + }); + let streamController!: ReadableStreamDefaultController; + const primedStream = new ReadableStream({ + start(controller) { + streamController = controller; + controller.enqueue(new TextEncoder().encode('id: ev-1\ndata: \n\n')); + } + }); + const fetchMock = globalThis.fetch as Mock; + let getSignal: AbortSignal | null | undefined; + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: primedStream + }); + fetchMock.mockImplementationOnce((_url, init: RequestInit) => { + getSignal = init.signal; + return Promise.resolve({ ok: false, status: 405, headers: new Headers() }); + }); + + const requestAbort = new AbortController(); + const onStreamEnd = vi.fn(); + await transport.start(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} }, + { requestSignal: requestAbort.signal, onRequestStreamEnd: onStreamEnd } + ); + await vi.advanceTimersByTimeAsync(5); + + // ACT — server closes the primed POST stream → schedules a GET resume → 405. + streamController.close(); + await vi.advanceTimersByTimeAsync(50); + + // ASSERT — onRequestStreamEnd fired exactly once on the 405; the + // resume was a single GET (no further retries — 405 resolves). + expect(onStreamEnd).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1]![1]?.method).toBe('GET'); + // requestSignal threaded through the GET reconnect: aborting the + // per-request signal aborts the resume's fetch signal. + expect(getSignal).toBeDefined(); + expect(getSignal?.aborted).toBe(false); + requestAbort.abort(); + expect(getSignal?.aborted).toBe(true); + }); + + it('per-request requestSignal abort BEFORE response headers: no misleading onerror; send() still rejects', async () => { + // ARRANGE — fetch is in flight (pending promise) when the + // requestSignal aborts; fetch rejects with AbortError before the + // SSE stream handler ever runs. _send's catch must apply the same + // intentional-abort guard as _handleSseStream. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + const errorSpy = vi.fn(); + transport.onerror = errorSpy; + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockImplementationOnce( + (_url, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }); + }) + ); + + const requestAbort = new AbortController(); + await transport.start(); + const sent = transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen-1', params: {} }, + { requestSignal: requestAbort.signal } + ); + // Let _send reach the in-flight fetch. + await vi.advanceTimersByTimeAsync(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // ACT — abort before headers. + requestAbort.abort(new Error('intentional')); + + // ASSERT — send() rejects (so listen()'s send-catch settles), but no onerror. + await expect(sent).rejects.toThrow(); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('anySignal fallback removes the sibling listener (no leak on the transport-lifetime signal)', async () => { + // ARRANGE — force the manual fallback path (Node 20.0–20.2). + const nativeAny = AbortSignal.any; + (AbortSignal as { any?: unknown }).any = undefined; + try { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockResolvedValue({ ok: true, status: 202, headers: new Headers() }); + await transport.start(); + + const transportSignal = (transport as unknown as { _abortController: AbortController })._abortController.signal; + const addSpy = vi.spyOn(transportSignal, 'addEventListener'); + const removeSpy = vi.spyOn(transportSignal, 'removeEventListener'); + + // ACT — N sends each with a fresh request-scoped signal that + // aborts after the send completes (the McpSubscription.close() + // pattern). Each send registers one fallback listener on the + // transport-lifetime signal; aborting the request-scoped + // signal must remove it. + for (let i = 0; i < 5; i++) { + const requestAbort = new AbortController(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: `listen-${i}`, params: {} }, + { requestSignal: requestAbort.signal } + ); + requestAbort.abort(); + } + + // ASSERT — every listener registered on the transport-lifetime + // signal was removed; nothing accrues per send(). + expect(addSpy.mock.calls.length).toBeGreaterThan(0); + expect(removeSpy.mock.calls.length).toBe(addSpy.mock.calls.length); + } finally { + (AbortSignal as { any?: unknown }).any = nativeAny; + } + }); + it('should NOT reconnect a POST stream when error response was received', async () => { // ARRANGE transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { @@ -1597,12 +2085,15 @@ describe('StreamableHTTPClientTransport', () => { expect(tokenCalls.length).toBeGreaterThan(0); // Verify tokens were saved - expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({ - access_token: 'new-access-token', - token_type: 'Bearer', - expires_in: 3600, - refresh_token: 'new-refresh-token' - }); + expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith( + expect.objectContaining({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }), + expect.anything() + ); // Global fetch should never have been called expect(globalThis.fetch).not.toHaveBeenCalled(); @@ -1876,12 +2367,15 @@ describe('StreamableHTTPClientTransport', () => { expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication); expect((error as SdkHttpError).status).toBe(401); expect((error as SdkHttpError).statusText).toBe('Unauthorized'); - expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({ - access_token: 'new-access-token', - token_type: 'Bearer', - expires_in: 3600, - refresh_token: 'refresh-token' // Refresh token is preserved - }); + expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith( + expect.objectContaining({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'refresh-token' // Refresh token is preserved + }), + expect.anything() + ); }); }); diff --git a/packages/client/test/client/versionNegotiation.test.ts b/packages/client/test/client/versionNegotiation.test.ts new file mode 100644 index 0000000000..b3f9d8df74 --- /dev/null +++ b/packages/client/test/client/versionNegotiation.test.ts @@ -0,0 +1,708 @@ +/** + * 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); + }); + + test('maxRetries (default 0) governs timeout re-sends only; the timeout verdict applies after retries are exhausted', async () => { + // HTTP-class: even with retries, a server that never answers produces a + // typed timeout error after maxRetries+1 probe sends — never a legacy verdict. + const transport = new ScriptedTransport(silentScript); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 20, maxRetries: 2 } } } + ); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout + ); + const probes = transport.sent.filter(m => 'method' in m && m.method === 'server/discover'); + expect(probes).toHaveLength(3); + expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); + }); + + test('maxRetries: a server that answers on the first retry resolves normally (the retry budget is timeout-only)', async () => { + let discoverCalls = 0; + const slowThenFastScript: Script = (message, t) => { + if (!isJSONRPCRequest(message) || message.method !== 'server/discover') return; + discoverCalls++; + // Ignore the first probe (forces a timeout); answer the retry. + if (discoverCalls === 1) return; + t.reply({ jsonrpc: '2.0', id: message.id, result: discoverResult([MODERN]) }); + }; + const transport = new ScriptedTransport(slowThenFastScript); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 20, maxRetries: 1 } } } + ); + + await client.connect(transport); + expect(discoverCalls).toBe(2); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + await client.close(); + }); +}); + +/* ------------------------------------------------------------------------- * + * -32022 corrective continuation — exactly once; loop guard on second + * rejection. + * ------------------------------------------------------------------------- */ + +describe('-32022 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_022, + 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_022, 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('-32022 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_022, 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('-32022 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_022, 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 -32022 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_022, 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/README.md b/packages/codemod/README.md new file mode 100644 index 0000000000..d498c07df8 --- /dev/null +++ b/packages/codemod/README.md @@ -0,0 +1,67 @@ +# @modelcontextprotocol/codemod + +Codemods for migrating MCP TypeScript SDK code between major versions. + +## Usage + +```bash +npx @modelcontextprotocol/codemod@alpha v1-to-v2 ./src +``` + +The codemod rewrites TypeScript and JavaScript source files +(`.ts`/`.tsx`/`.mts`/`.cts`/`.js`/`.jsx`/`.mjs`/`.cjs`) in place. Run it on a clean +working tree so you can review the diff. + +## What `v1-to-v2` covers + +The mechanical rename mappings are the source of truth — see +`src/migrations/v1-to-v2/mappings/`: + +- [`importMap.ts`](./src/migrations/v1-to-v2/mappings/importMap.ts) — + `@modelcontextprotocol/sdk/...` import paths → v2 packages +- [`symbolMap.ts`](./src/migrations/v1-to-v2/mappings/symbolMap.ts) — + symbol renames (`McpError` → `ProtocolError`, …) +- [`schemaToMethodMap.ts`](./src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts) — + `setRequestHandler(Schema, …)` → `setRequestHandler('method/string', …)` +- [`contextPropertyMap.ts`](./src/migrations/v1-to-v2/mappings/contextPropertyMap.ts) — + `extra.*` → `ctx.mcpReq.*` / `ctx.http?.*` + +Transforms in `src/migrations/v1-to-v2/transforms/` also rewrite `.tool()` → +`registerTool` (wrapping `inputSchema` / `outputSchema` / `argsSchema` / `uriSchema` +raw shapes with `z.object()`), drop the result-schema argument from `client.request()` +/ `client.callTool()` for spec methods, rewrite spec-`*Schema` +constant accesses (`.safeParse` → `isSpecType` / `specTypeSchemas`), rename +`StreamableHTTPError` → `SdkHttpError` / `IsomorphicHeaders` → `Headers`, rewrite +`SchemaInput` → `StandardSchemaWithJSON.InferInput`, route +`ErrorCode.{RequestTimeout,ConnectionClosed}` to `SdkErrorCode`, and rewrite `vi.mock` +/ `jest.mock` / dynamic `import()` paths. + +## `@mcp-codemod-error` markers + +When the codemod recognizes a v1 pattern but cannot safely rewrite it (ambiguous +context, removed API with no mechanical replacement, signature change requiring +judgment), it leaves the code unchanged and inserts a comment: + +```typescript +/* @mcp-codemod-error WebSocketClientTransport removed in v2. Use StreamableHTTPClientTransport or StdioClientTransport. */ +``` + +After running the codemod, find every site that needs attention: + +```bash +grep -rn '@mcp-codemod-error' . +``` + +## What it does NOT cover + +CJS→ESM / Node 20 pre-flight, `new Headers()` / `.get()` access rewrites, OAuth +error-class consolidation (`instanceof InvalidGrantError` → `OAuthError` + +`OAuthErrorCode`), per-scenario `SdkErrorCode` branch selection, `ctx.mcpReq.send()` +schema-arg drop, and behavioral adaptation are manual — see the +[migration guide](../../docs/migration/upgrade-to-v2.md) for what to do after the +codemod runs. + +The codemod handles the v1→v2 SDK surface upgrade only. Adopting the 2026-07-28 +protocol revision (`createMcpHandler`, multi-round-trip requests, `versionNegotiation`) +is architectural and not codemod-automatable — see +[docs/migration/support-2026-07-28.md](../../docs/migration/support-2026-07-28.md). diff --git a/packages/codemod/src/generated/specSchemaMap.ts b/packages/codemod/src/generated/specSchemaMap.ts index 77f3d3dfc8..4b130b5e6f 100644 --- a/packages/codemod/src/generated/specSchemaMap.ts +++ b/packages/codemod/src/generated/specSchemaMap.ts @@ -114,7 +114,6 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'ReadResourceResultSchema', 'RelatedTaskMetadataSchema', 'RequestIdSchema', - 'RequestMetaEnvelopeSchema', 'RequestMetaSchema', 'RequestSchema', 'ResourceContentsSchema', @@ -143,6 +142,13 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'StringSchemaSchema', 'SubscribeRequestParamsSchema', 'SubscribeRequestSchema', + 'SubscriptionFilterSchema', + 'SubscriptionsAcknowledgedNotificationParamsSchema', + 'SubscriptionsAcknowledgedNotificationSchema', + 'SubscriptionsListenRequestParamsSchema', + 'SubscriptionsListenRequestSchema', + 'SubscriptionsListenResultMetaSchema', + 'SubscriptionsListenResultSchema', 'TaskAugmentedRequestParamsSchema', 'TaskCreationParamsSchema', 'TaskMetadataSchema', diff --git a/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts b/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts index 24d086f8de..ffc4a2268f 100644 --- a/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts +++ b/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts @@ -25,6 +25,10 @@ export const IMPORT_MAP: Record = { target: '@modelcontextprotocol/client', status: 'moved' }, + '@modelcontextprotocol/sdk/client/auth-extensions.js': { + target: '@modelcontextprotocol/client', + status: 'moved' + }, '@modelcontextprotocol/sdk/client/streamableHttp.js': { target: '@modelcontextprotocol/client', status: 'moved' @@ -83,6 +87,14 @@ export const IMPORT_MAP: Record = { target: '@modelcontextprotocol/express', status: 'moved' }, + '@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js': { + target: '@modelcontextprotocol/express', + status: 'moved' + }, + '@modelcontextprotocol/sdk/server/express.js': { + target: '@modelcontextprotocol/express', + status: 'moved' + }, '@modelcontextprotocol/sdk/server/zod-compat.js': { target: '', status: 'removed', @@ -131,6 +143,14 @@ export const IMPORT_MAP: Record = { target: 'RESOLVE_BY_CONTEXT', status: 'moved' }, + '@modelcontextprotocol/sdk/shared/auth-utils.js': { + target: 'RESOLVE_BY_CONTEXT', + status: 'moved' + }, + '@modelcontextprotocol/sdk/client/middleware.js': { + target: '@modelcontextprotocol/client', + status: 'moved' + }, '@modelcontextprotocol/sdk/shared/uriTemplate.js': { target: 'RESOLVE_BY_CONTEXT', status: 'moved' diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/expressMiddleware.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/expressMiddleware.ts deleted file mode 100644 index 319461070a..0000000000 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/expressMiddleware.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { SourceFile } from 'ts-morph'; -import { Node, SyntaxKind } from 'ts-morph'; - -import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; -import { info } from '../../../utils/diagnostics.js'; -import { isOriginalNameImportedFromMcp, resolveLocalImportName } from '../../../utils/importUtils.js'; - -export const expressMiddlewareTransform: Transform = { - name: 'Express middleware signature migration', - id: 'express-middleware', - apply(sourceFile: SourceFile, _context: TransformContext): TransformResult { - if (!isOriginalNameImportedFromMcp(sourceFile, 'hostHeaderValidation')) { - return { changesCount: 0, diagnostics: [] }; - } - - const diagnostics: Diagnostic[] = []; - let changesCount = 0; - - const localName = resolveLocalImportName(sourceFile, 'hostHeaderValidation') ?? 'hostHeaderValidation'; - changesCount += rewriteHostHeaderValidation(sourceFile, localName, diagnostics); - - return { changesCount, diagnostics }; - } -}; - -function rewriteHostHeaderValidation(sourceFile: SourceFile, targetName: string, diagnostics: Diagnostic[]): number { - let changesCount = 0; - - const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression); - - for (const call of calls) { - const expr = call.getExpression(); - if (!Node.isIdentifier(expr) || expr.getText() !== targetName) continue; - - const args = call.getArguments(); - if (args.length !== 1) continue; - - const firstArg = args[0]!; - if (!Node.isObjectLiteralExpression(firstArg)) continue; - - const allowedHostsProp = firstArg.getProperty('allowedHosts'); - if (!allowedHostsProp || !Node.isPropertyAssignment(allowedHostsProp)) continue; - - const initializer = allowedHostsProp.getInitializer(); - if (!initializer) continue; - - const arrayText = initializer.getText(); - firstArg.replaceWithText(arrayText); - changesCount++; - - diagnostics.push( - info( - sourceFile.getFilePath(), - call.getStartLineNumber(), - 'hostHeaderValidation({ allowedHosts: [...] }) simplified to hostHeaderValidation([...]). Verify the migration.' - ) - ); - } - - return changesCount; -} diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts index 7e6645dc56..b76e501306 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts @@ -49,7 +49,7 @@ export const handlerRegistrationTransform: Transform = { call, `Custom method handler: ${methodName}(${schemaName}, ...). ` + `In v2, use the 3-arg form: ${methodName}('method/name', { params, result? }, handler). ` + - `See migration.md for details.` + `See docs/migration/upgrade-to-v2.md for details.` ) ); continue; diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/index.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/index.ts index 7b6b54b28b..fe00099514 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/index.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/index.ts @@ -1,6 +1,5 @@ import type { Transform } from '../../../types.js'; import { contextTypesTransform } from './contextTypes.js'; -import { expressMiddlewareTransform } from './expressMiddleware.js'; import { handlerRegistrationTransform } from './handlerRegistration.js'; import { importPathsTransform } from './importPaths.js'; import { mcpServerApiTransform } from './mcpServerApi.js'; @@ -28,8 +27,8 @@ import { symbolRenamesTransform } from './symbolRenames.js'; // to .registerTool() etc. contextTypes handles both old and new names, // but running mcpServerApi first ensures consistent argument structure. // -// 5. handlerRegistration, schemaParamRemoval, and expressMiddleware are -// independent of each other but all depend on importPaths having run. +// 5. handlerRegistration and schemaParamRemoval are independent of each +// other but both depend on importPaths having run. // // 6. specSchemaAccess runs after handlerRegistration and schemaParamRemoval: // those transforms remove spec schema references they handle. specSchemaAccess @@ -45,7 +44,6 @@ export const v1ToV2Transforms: Transform[] = [ handlerRegistrationTransform, schemaParamRemovalTransform, specSchemaAccessTransform, - expressMiddlewareTransform, contextTypesTransform, mockPathsTransform ]; 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..631c995884 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts @@ -107,6 +107,9 @@ export const mcpServerApiTransform: Transform = { if (wrapSchemaInConfig(call, 'inputSchema', sourceFile, diagnostics)) { changesCount++; } + if (wrapSchemaInConfig(call, 'outputSchema', sourceFile, diagnostics)) { + changesCount++; + } } for (const call of registerPromptCalls) { @@ -121,7 +124,7 @@ export const mcpServerApiTransform: Transform = { } } - changesCount += migrateConstructorTaskOptions(sourceFile, diagnostics); + flagRemovedTaskOptions(sourceFile, diagnostics); return { changesCount, diagnostics }; } @@ -414,11 +417,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 +440,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 - }); - } - 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 (!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.` + ) + ); } - - 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/integration.test.ts b/packages/codemod/test/integration.test.ts index eeb9de2e17..18942bfcfa 100644 --- a/packages/codemod/test/integration.test.ts +++ b/packages/codemod/test/integration.test.ts @@ -293,7 +293,7 @@ describe('integration', () => { expect(output).toContain('McpError'); }); - it('applies new transforms (removed APIs, SchemaInput, express middleware)', () => { + it('applies new transforms (removed APIs, SchemaInput, middleware import)', () => { const dir = createTempDir(); const input = [ `import { McpServer, schemaToJson, IsomorphicHeaders } from '@modelcontextprotocol/sdk/server/mcp.js';`, @@ -304,7 +304,7 @@ describe('integration', () => { `type Input = SchemaInput;`, `const h: IsomorphicHeaders = {};`, `if (error instanceof StreamableHTTPError) {}`, - `app.use(hostHeaderValidation({ allowedHosts: ['localhost'] }));`, + `app.use(hostHeaderValidation(['localhost']));`, `` ].join('\n'); @@ -331,9 +331,9 @@ describe('integration', () => { // schemaToJson removed (import gone) expect(output).not.toContain('schemaToJson'); - // hostHeaderValidation signature migrated + // hostHeaderValidation import rewritten to @modelcontextprotocol/express; call unchanged expect(output).toContain("hostHeaderValidation(['localhost'])"); - expect(output).not.toContain('allowedHosts'); + expect(output).toContain('@modelcontextprotocol/express'); // Diagnostics emitted expect(result.diagnostics.length).toBeGreaterThan(0); @@ -470,7 +470,7 @@ describe('integration', () => { [ `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, `import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware.js';`, - `app.use(hostHeaderValidation({ allowedHosts: ['localhost'] }));`, + `app.use(hostHeaderValidation(['localhost']));`, `` ].join('\n') ); diff --git a/packages/codemod/test/v1-to-v2/transforms/expressMiddleware.test.ts b/packages/codemod/test/v1-to-v2/transforms/expressMiddleware.test.ts deleted file mode 100644 index 35182e47b2..0000000000 --- a/packages/codemod/test/v1-to-v2/transforms/expressMiddleware.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { Project } from 'ts-morph'; - -import { expressMiddlewareTransform } from '../../../src/migrations/v1-to-v2/transforms/expressMiddleware.js'; -import type { TransformContext } from '../../../src/types.js'; - -const ctx: TransformContext = { projectType: 'server' }; - -function applyTransform(code: string) { - const project = new Project({ useInMemoryFileSystem: true }); - const sourceFile = project.createSourceFile('test.ts', code); - const result = expressMiddlewareTransform.apply(sourceFile, ctx); - return { text: sourceFile.getFullText(), result }; -} - -describe('express-middleware transform', () => { - it('rewrites hostHeaderValidation({ allowedHosts: [...] }) to hostHeaderValidation([...])', () => { - const input = [ - `import { hostHeaderValidation } from '@modelcontextprotocol/express';`, - `app.use(hostHeaderValidation({ allowedHosts: ['localhost', '127.0.0.1'] }));`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain("hostHeaderValidation(['localhost', '127.0.0.1'])"); - expect(text).not.toContain('allowedHosts'); - expect(result.changesCount).toBe(1); - }); - - it('preserves calls that already use array syntax', () => { - const input = [ - `import { hostHeaderValidation } from '@modelcontextprotocol/express';`, - `app.use(hostHeaderValidation(['localhost', '127.0.0.1']));`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain("hostHeaderValidation(['localhost', '127.0.0.1'])"); - expect(result.changesCount).toBe(0); - }); - - it('handles variable references in allowedHosts', () => { - const input = [ - `import { hostHeaderValidation } from '@modelcontextprotocol/express';`, - `const hosts = ['localhost'];`, - `app.use(hostHeaderValidation({ allowedHosts: hosts }));`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain('hostHeaderValidation(hosts)'); - expect(text).not.toContain('allowedHosts'); - expect(result.changesCount).toBe(1); - }); - - it('does not modify calls with non-object arguments', () => { - const input = [ - `import { hostHeaderValidation } from '@modelcontextprotocol/express';`, - `app.use(hostHeaderValidation(someVariable));`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain('hostHeaderValidation(someVariable)'); - expect(result.changesCount).toBe(0); - }); - - it('does not modify calls with no arguments', () => { - const input = [ - `import { hostHeaderValidation } from '@modelcontextprotocol/express';`, - `app.use(hostHeaderValidation());`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(text).toContain('hostHeaderValidation()'); - expect(result.changesCount).toBe(0); - }); - - it('is idempotent', () => { - const input = [ - `import { hostHeaderValidation } from '@modelcontextprotocol/express';`, - `app.use(hostHeaderValidation({ allowedHosts: ['localhost'] }));`, - '' - ].join('\n'); - const { text: first } = applyTransform(input); - const { text: second } = applyTransform(first); - expect(second).toBe(first); - }); - - it('does not modify calls when hostHeaderValidation is not from MCP', () => { - const input = [ - `import { hostHeaderValidation } from './my-middleware.js';`, - `app.use(hostHeaderValidation({ allowedHosts: ['localhost'] }));`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(result.changesCount).toBe(0); - expect(text).toContain("{ allowedHosts: ['localhost'] }"); - }); - - it('applies transform when hostHeaderValidation is aliased', () => { - const input = [ - `import { hostHeaderValidation as hhv } from '@modelcontextprotocol/express';`, - `app.use(hhv({ allowedHosts: ['localhost'] }));`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(result.changesCount).toBe(1); - expect(text).toContain("hhv(['localhost'])"); - expect(text).not.toContain('allowedHosts'); - }); - - it('does not modify non-MCP hostHeaderValidation even when other MCP imports exist', () => { - const input = [ - `import { McpServer } from '@modelcontextprotocol/server';`, - `import { hostHeaderValidation } from './my-middleware.js';`, - `app.use(hostHeaderValidation({ allowedHosts: ['localhost'] }));`, - '' - ].join('\n'); - const { text, result } = applyTransform(input); - expect(result.changesCount).toBe(0); - expect(text).toContain("{ allowedHosts: ['localhost'] }"); - }); -}); diff --git a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts index f0563f9f69..74fd5f3cb7 100644 --- a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts @@ -128,6 +128,38 @@ describe('import-paths transform', () => { expect(result).toContain(`from "@modelcontextprotocol/express"`); }); + it('rewrites server/express.js import to @modelcontextprotocol/express', () => { + const input = `import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';\n`; + const result = applyTransform(input); + expect(result).toContain(`from "@modelcontextprotocol/express"`); + expect(result).toContain('createMcpExpressApp'); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + + it('rewrites deep middleware/hostHeaderValidation.js import to @modelcontextprotocol/express', () => { + const input = `import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js';\n`; + const result = applyTransform(input); + expect(result).toContain(`from "@modelcontextprotocol/express"`); + expect(result).toContain('hostHeaderValidation'); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + + it('rewrites client/auth-extensions.js import to @modelcontextprotocol/client', () => { + const input = `import { discoverAuthorizationServerMetadata } from '@modelcontextprotocol/sdk/client/auth-extensions.js';\n`; + const result = applyTransform(input); + expect(result).toContain(`from "@modelcontextprotocol/client"`); + expect(result).toContain('discoverAuthorizationServerMetadata'); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + + it('moves deep server/auth/middleware/bearerAuth.js to server-legacy/auth via catch-all', () => { + const input = `import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';\n`; + const result = applyTransform(input, { projectType: 'server' }); + expect(result).toContain('@modelcontextprotocol/server-legacy/auth'); + expect(result).toContain('requireBearerAuth'); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + it('renames body references when renamedSymbols applies', () => { const input = [ `import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';`, 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..e775a0c5ff 100644 --- a/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts @@ -240,6 +240,43 @@ describe('mcp-server-api transform', () => { expect(result).not.toContain('inputSchema: { msg:'); }); + it('wraps raw outputSchema in .registerTool() config', () => { + const input = [ + `server.registerTool("echo", { outputSchema: { result: z.string() } }, async () => {`, + ` return { content: [], structuredContent: { result: 'ok' } };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('outputSchema: z.object({ result: z.string() })'); + expect(result).not.toContain('outputSchema: { result:'); + }); + + it('wraps both raw inputSchema and outputSchema in the same .registerTool() config', () => { + const input = [ + `server.registerTool("echo", { inputSchema: { msg: z.string() }, outputSchema: { result: z.string() } }, async ({ msg }) => {`, + ` return { content: [], structuredContent: { result: msg } };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('inputSchema: z.object({ msg: z.string() })'); + expect(result).toContain('outputSchema: z.object({ result: z.string() })'); + expect(result).not.toContain('z.object(z.object('); + }); + + it('does not double-wrap z.object() outputSchema in .registerTool() config', () => { + const input = [ + `server.registerTool("echo", { outputSchema: z.object({ result: z.string() }) }, async () => {`, + ` return { content: [], structuredContent: { result: 'ok' } };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('outputSchema: z.object({ result: z.string() })'); + expect(result).not.toContain('z.object(z.object('); + }); + it('does not double-wrap z.object() in .registerTool() config', () => { const input = [ `server.registerTool("echo", { inputSchema: z.object({ msg: z.string() }) }, async ({ msg }) => {`, @@ -307,4 +344,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/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts b/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts index f1a2413982..d2d6c86482 100644 --- a/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts @@ -63,6 +63,19 @@ describe('schema-param-removal transform', () => { expect(result).toContain('MyCustomSchema'); }); + it('does not remove a non-MCP schema from extra.sendRequest() for a custom method', () => { + const input = [ + `import { MySchema } from './my-schemas';`, + `const result = await extra.sendRequest({ method: 'acme/x', params }, MySchema);`, + '' + ].join('\n'); + const result = applyTransform(input); + // The schema arg and its import must be left alone — only MCP-imported + // *Schema identifiers are stripped (same guard as the request/callTool path). + expect(result).toContain("extra.sendRequest({ method: 'acme/x', params }, MySchema)"); + expect(result).toContain(`import { MySchema } from './my-schemas';`); + }); + it('is idempotent', () => { const input = [ `import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, diff --git a/packages/core/eslint.config.mjs b/packages/core/eslint.config.mjs index 951c9f3a91..a142acd806 100644 --- a/packages/core/eslint.config.mjs +++ b/packages/core/eslint.config.mjs @@ -2,4 +2,53 @@ import baseConfig from '@modelcontextprotocol/eslint-config'; -export default baseConfig; +export default [ + ...baseConfig, + { + // Wire-layer isolation, outbound direction: nothing outside src/wire/ may + // reach into a wire revision module. The wire layer's only public surface + // is src/wire/codec.ts (the WireCodec interface) and src/wire/bootstrap.ts. + // test/wire/layeringInvariants.test.ts re-derives the same invariant with + // zero exceptions. Type-only imports are exempted at the lint layer (a + // type-only crossing is erased at runtime), but the test allows none. + files: ['src/**/*.ts'], + ignores: ['src/wire/**'], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['**/wire/rev*', '**/wire/rev*/**', '@modelcontextprotocol/core/wire/rev*'], + allowTypeImports: true, + message: 'Wire revision modules are codec-private. Route through src/wire/codec.ts (WireCodec) instead.' + } + ] + } + ] + } + }, + { + // Wire-layer isolation, inbound direction: wire revision modules are frozen, + // self-contained schema sets — they must not import the public-layer schema + // module at runtime. A change to types/schemas.ts must never alter what a + // codec emits or accepts on the wire. Type-only imports stay permitted. + files: ['src/wire/rev*/**/*.ts'], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['**/types/schemas', '**/types/schemas.js'], + allowTypeImports: true, + message: + 'Wire revision modules must be self-contained. Freeze a copy of the schema into the ' + + 'rev*/ directory instead of importing the mutable public-layer types/schemas.ts.' + } + ] + } + ] + } + } +]; diff --git a/packages/core/src/auth/errors.ts b/packages/core/src/auth/errors.ts index 30c8741601..7aba02640d 100644 --- a/packages/core/src/auth/errors.ts +++ b/packages/core/src/auth/errors.ts @@ -83,6 +83,11 @@ export enum OAuthErrorCode { */ InvalidClientMetadata = 'invalid_client_metadata', + /** + * The value of one or more redirection URIs is invalid. (Dynamic client registration - RFC 7591 §3.2.2) + */ + InvalidRedirectUri = 'invalid_redirect_uri', + /** * The request requires higher privileges than provided by the access token. */ diff --git a/packages/core/src/errors/sdkErrors.ts b/packages/core/src/errors/sdkErrors.ts index af432c6389..b808d98877 100644 --- a/packages/core/src/errors/sdkErrors.ts +++ b/packages/core/src/errors/sdkErrors.ts @@ -28,6 +28,47 @@ 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 multi-round-trip auto-fulfilment driver exhausted its round cap + * (`inputRequired.maxRounds`) without the server returning a complete + * result. `data.rounds` carries the cap that was hit and + * `data.lastResult` carries the last `input_required` payload received + * (`{ inputRequests, requestState? }`), so callers can inspect or resume + * the flow manually. + */ + InputRequiredRoundsExceeded = 'INPUT_REQUIRED_ROUNDS_EXCEEDED', + /** + * The auto-aggregating no-`cursor` `listTools()` / `listPrompts()` / + * `listResources()` / `listResourceTemplates()` walk hit the + * `ClientOptions.listMaxPages` cap without the server's pagination + * converging. `data.method` carries the list verb and + * `data.listMaxPages` the cap that was hit; raise the cap or fall back to + * explicit per-page `{ cursor }` calls. + */ + ListPaginationExceeded = 'LIST_PAGINATION_EXCEEDED', + /** + * 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..c7cc53e0cb 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -30,7 +30,9 @@ export type { OAuthTokenRevocationRequest, OAuthTokens, OpenIdProviderDiscoveryMetadata, - OpenIdProviderMetadata + OpenIdProviderMetadata, + StoredOAuthClientInformation, + StoredOAuthTokens } from '../../shared/auth.js'; // Auth utilities @@ -85,16 +87,26 @@ export { PARSE_ERROR, PROTOCOL_VERSION_META_KEY, RELATED_TASK_META_KEY, + SUBSCRIPTION_ID_META_KEY, SUPPORTED_PROTOCOL_VERSIONS, TRACEPARENT_META_KEY, TRACESTATE_META_KEY } from '../../types/constants.js'; +// Protocol-era helpers +export type { ProtocolEra } from '../../shared/protocolEras.js'; + // Enums export { ProtocolErrorCode } from '../../types/enums.js'; // Error classes -export { ProtocolError, UnsupportedProtocolVersionError, UrlElicitationRequiredError } from '../../types/errors.js'; +export { + MissingRequiredClientCapabilityError, + ProtocolError, + ResourceNotFoundError, + UnsupportedProtocolVersionError, + UrlElicitationRequiredError +} from '../../types/errors.js'; // Type guards and message parsing export { @@ -103,6 +115,7 @@ export { isCallToolResult, isInitializedNotification, isInitializeRequest, + isInputRequiredResult, isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a704267ee3..0141475f26 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,17 +2,35 @@ 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/inputRequired.js'; +export * from './shared/inputRequiredDriver.js'; +export * from './shared/inputRequiredEngine.js'; +export * from './shared/mcpParamHeaders.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: 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), plus the +// internal modern-revision literal so sibling packages can name the era a +// 2026-only seam runs in. NOTHING per-revision (registries, codec objects, +// per-revision schemas) is ever exported on this barrel — sibling packages +// reach the wire layer ONLY through `codecForVersion`'s function-only +// `WireCodec` surface. export * from './util/schema.js'; export * from './util/standardSchema.js'; export * from './util/zodCompat.js'; +export { codecForVersion, MODERN_WIRE_REVISION } 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/auth.ts b/packages/core/src/shared/auth.ts index deee583aa1..e21076d817 100644 --- a/packages/core/src/shared/auth.ts +++ b/packages/core/src/shared/auth.ts @@ -66,7 +66,9 @@ export const OAuthMetadataSchema = z.looseObject({ introspection_endpoint_auth_methods_supported: z.array(z.string()).optional(), introspection_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(), code_challenge_methods_supported: z.array(z.string()).optional(), - client_id_metadata_document_supported: z.boolean().optional() + client_id_metadata_document_supported: z.boolean().optional(), + // eslint-disable-next-line unicorn/prefer-top-level-await -- Zod .catch(), not a Promise chain + authorization_response_iss_parameter_supported: z.boolean().optional().catch(undefined) }); /** @@ -110,7 +112,9 @@ export const OpenIdProviderMetadataSchema = z.looseObject({ require_request_uri_registration: z.boolean().optional(), op_policy_uri: SafeUrlSchema.optional(), op_tos_uri: SafeUrlSchema.optional(), - client_id_metadata_document_supported: z.boolean().optional() + client_id_metadata_document_supported: z.boolean().optional(), + // eslint-disable-next-line unicorn/prefer-top-level-await -- Zod .catch(), not a Promise chain + authorization_response_iss_parameter_supported: z.boolean().optional().catch(undefined) }); /** @@ -182,6 +186,15 @@ export const OAuthClientMetadataSchema = z token_endpoint_auth_method: z.string().optional(), grant_types: z.array(z.string()).optional(), response_types: z.array(z.string()).optional(), + /** + * OIDC Dynamic Client Registration `application_type`. MCP clients MUST set + * this to `'native'` or `'web'` when registering (SEP-837); the SDK defaults + * it from `redirect_uris` when omitted. Typed as `string` (not an enum) so + * that parsing an authorization server's registration response — which under + * RFC 7591 may echo extension values — never rejects the document on this + * field alone. + */ + application_type: z.string().optional(), client_name: z.string().optional(), client_uri: SafeUrlSchema.optional(), logo_uri: OptionalSafeUrlSchema, @@ -244,6 +257,26 @@ export type OAuthClientMetadata = z.infer; export type OAuthClientInformation = z.infer; export type OAuthClientInformationFull = z.infer; export type OAuthClientInformationMixed = OAuthClientInformation | OAuthClientInformationFull; + +/** + * {@linkcode OAuthTokens} as persisted by an `OAuthClientProvider`. Adds an + * SDK-stamped authorization-server `issuer` identifier so stored tokens are + * bound to the AS that issued them. The `issuer` field is **not** part of the + * RFC 6749 wire response and is intentionally absent from the wire-response + * schema; the client SDK writes it before calling `saveTokens`. + */ +export type StoredOAuthTokens = OAuthTokens & { issuer?: string }; + +/** + * {@linkcode OAuthClientInformationMixed} as persisted by an + * `OAuthClientProvider`. Adds an SDK-stamped authorization-server `issuer` + * identifier so stored client credentials are bound to the AS that issued them. + * The `issuer` field is **not** part of the RFC 7591 wire response and is + * intentionally absent from the wire-response schema; the client SDK writes it + * before calling `saveClientInformation`. + */ +export type StoredOAuthClientInformation = OAuthClientInformationMixed & { issuer?: string }; + export type OAuthClientRegistrationError = z.infer; export type OAuthTokenRevocationRequest = z.infer; export type OAuthProtectedResourceMetadata = z.infer; diff --git a/packages/core/src/shared/clientCapabilityRequirements.ts b/packages/core/src/shared/clientCapabilityRequirements.ts new file mode 100644 index 0000000000..59b3d5086f --- /dev/null +++ b/packages/core/src/shared/clientCapabilityRequirements.ts @@ -0,0 +1,160 @@ +/** + * 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` (`-32021`) 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); +} + +/** + * Whether a required nested member counts as declared even though it is not + * spelled out: a bare `elicitation: {}` declaration (no mode sub-capability at + * all) is read as form support — the pre-mode (2025) meaning of a bare + * declaration — so an `elicitation.form` requirement treats it as satisfied. + * Declaring any mode explicitly (for example `elicitation: { url: {} }`) + * removes the implication. + */ +function isImpliedCapabilityMember(capability: string, member: string, declaredValue: Record): boolean { + return capability === 'elicitation' && member === 'form' && declaredValue['form'] === undefined && declaredValue['url'] === undefined; +} + +/** + * The client capabilities an embedded multi-round-trip input request requires + * (call site 2 — the outbound input-request leg): a server MUST NOT send an + * `inputRequests` kind the request's declared client capabilities do not + * cover. Returns `undefined` for entries whose method is not one of the + * embedded input-request kinds (those are a server bug handled separately, + * not a capability question). + * + * The requirement is mode-aware where the capability is: URL-mode elicitation + * requires `elicitation.url`; form-mode (or mode-omitted) elicitation requires + * `elicitation.form` (modes are sub-capabilities, and a server MUST NOT send a + * mode the client did not declare); sampling with `tools`/`toolChoice` + * requires `sampling.tools`. A bare `elicitation: {}` declaration satisfies + * the form requirement — see {@linkcode missingClientCapabilities}. + */ +export function requiredClientCapabilitiesForInputRequest(entry: { + method: string; + params?: Record; +}): ClientCapabilities | undefined { + switch (entry.method) { + case 'elicitation/create': { + if (entry.params?.['mode'] === 'url') { + return { elicitation: { url: {} } }; + } + return { elicitation: { form: {} } }; + } + case 'sampling/createMessage': { + const params = entry.params; + if (params !== undefined && (params['tools'] !== undefined || params['toolChoice'] !== undefined)) { + return { sampling: { tools: {} } }; + } + return { sampling: {} }; + } + case 'roots/list': { + return { roots: {} }; + } + default: { + return undefined; + } + } +} + +/** + * 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 `-32021` 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. One lenient reading applies: a bare + * `elicitation: {}` declaration (no mode sub-capability at all) counts as + * declaring `elicitation.form` — the pre-mode (2025) meaning of a bare + * declaration. 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 && + !isImpliedCapabilityMember(capability, member, declaredValue) + ) { + 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..05fd71b9d0 --- /dev/null +++ b/packages/core/src/shared/envelope.ts @@ -0,0 +1,78 @@ +/** + * 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. + * + * The wire-exact envelope schema, the required-key set, and the per-key issue + * mapping live in the wire layer (the 2026-era codec's `validateEnvelopeMeta`). + * This module never reaches into a per-revision wire module directly. + */ +import { PROTOCOL_VERSION_META_KEY } from '../types/constants.js'; +import type { EnvelopeIssue } from '../wire/codec.js'; +import { codecForVersion, MODERN_WIRE_REVISION } from '../wire/codec.js'; + +// Re-export from the wire layer (the canonical home): the issue shape is part +// of the function-only WireCodec contract. Imported above for the local return +// type, so the bare re-export form is used. +// eslint-disable-next-line unicorn/prefer-export-from +export type { EnvelopeIssue }; + +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[] { + // Delegate to the era codec: the required-key pre-pass and the wire-exact + // `RequestMetaEnvelopeSchema` parse live in `wire/rev2026-07-28/` — this + // module never reaches into per-revision wire vocabulary. + return codecForVersion(MODERN_WIRE_REVISION).validateEnvelopeMeta(meta); +} diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts new file mode 100644 index 0000000000..ca3cf9bda5 --- /dev/null +++ b/packages/core/src/shared/inboundClassification.ts @@ -0,0 +1,903 @@ +/** + * 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. + * The notification-POST header cross-checks here are an SDK-defensive + * posture, not a spec requirement: the spec leaves header rules for posted + * notifications undefined (core client notifications do not occur over + * Streamable HTTP); applying the request rules symmetrically is what an + * ecosystem custom-notification POST expects, and the −32020 cells stay + * passing for them. + * - `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 `-32020` (`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 { JSONRPCNotification, JSONRPCRequest, MessageClassification } from '../types/types.js'; +import { envelopeClaimVersion, hasEnvelopeClaim, requestMetaOf, validateEnvelopeMeta } from './envelope.js'; +// Value encoding is shared between the standard `Mcp-Name` header and the +// custom `Mcp-Param-*` headers; the codec module already imports the +// `HeaderMismatch` constant and rejection type from here, so this is a benign +// two-module cycle (both sides only consume the other's exports inside +// function bodies, never at module-evaluation time). +import { decodeMcpParamValue } from './mcpParamHeaders.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 value of the `Mcp-Name` header, when present. */ + mcpNameHeader?: 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. Discriminated by `messageKind` so the typed `message` narrows + * with it — the classifier has already proved the JSON-RPC shape via the + * `isJSONRPCRequest` / `isJSONRPCNotification` guards, so consumers never + * cast the body again. + */ +export type InboundModernRoute = + | { + kind: 'modern'; + messageKind: 'request'; + /** The classified body — guard-proved {@linkcode JSONRPCRequest} shape. */ + message: JSONRPCRequest; + /** + * The classification handed to the per-request transport and validated by + * the protocol layer against the serving instance's negotiated era. + */ + classification: MessageClassification; + } + | { + kind: 'modern'; + messageKind: 'notification'; + /** The classified body — guard-proved {@linkcode JSONRPCNotification} shape. */ + message: JSONRPCNotification; + 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' + | 'standard-header-validation' + | 'client-capabilities' + | 'param-header-validation'; + +/** 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. + * + * `-32020` is the draft schema's `HEADER_MISMATCH` constant (the SEP-2243 + * `HeaderMismatch` code; the spec requires HTTP 400 for it), as also 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_020; + +/* ------------------------------------------------------------------------ * + * 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 -32020 (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: 'standard-header-validation', + order: 7, + evaluatedAt: 'pre-dispatch', + codes: [HEADER_MISMATCH_ERROR_CODE], + conformance: ['http-header-validation'], + rationale: + 'SEP-2243 standard `Mcp-Method` / `Mcp-Name` headers — presence, sentinel decoding, and `Mcp-Name` ↔ body cross-check ' + + '— are validated by the HTTP entry on a modern-classified request after the supported-revision gate and before ' + + 'dispatch. The classifier’s own header-mismatch cells (protocol-version, `Mcp-Method` mismatch) stay on the edge ' + + '`era-classification` rung; this rung carries the entry-layer presence/`Mcp-Name` half. Evaluated before the ' + + 'capability gate, the factory call, and the `Mcp-Param-*` rung so a request that fails several rungs is answered by ' + + 'the standard-header rung first. The documented order (after method-registry 5 and request-params 6) is NOT the ' + + 'observed precedence: serveModern evaluates this rung immediately after the supported-revision gate, so a request ' + + 'that also fails a dispatch rung is answered here before the dispatch rungs (5–6) are consulted.' + }, + { + rung: 'client-capabilities', + order: 8, + 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.' + }, + { + rung: 'param-header-validation', + order: 9, + evaluatedAt: 'pre-dispatch', + codes: [HEADER_MISMATCH_ERROR_CODE], + conformance: ['http-custom-header-server-validation'], + rationale: + 'SEP-2243 `Mcp-Param-*` headers are validated against the named tool’s `x-mcp-header` declarations and the body ' + + '`arguments` after the tool registry is known and before dispatch reaches the handler; a missing/disagreeing/malformed ' + + 'header is rejected 400 / -32020 with the same shape as the standard-header cross-checks. The documented order ' + + '(after method resolution and params validation) is preserved observably only when the body `arguments` would ' + + 'otherwise validate: the check runs pre-dispatch, so a `tools/call` that fails BOTH this rung and a dispatch-time ' + + 'rung (e.g. order-6 `request-params`, -32602) is answered by this gate first with 400 / -32020, not by the ' + + 'earlier-ordered rung.' + } +]; + +/* ------------------------------------------------------------------------ * + * 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, + rung: InboundValidationRung = 'era-classification' +): InboundLadderRejection { + return rejection( + rung, + cell, + 400, + new ProtocolError(HEADER_MISMATCH_ERROR_CODE, `Bad Request: the request headers and body disagree: ${body}`, { + mismatch: { header, body } + }), + true + ); +} + +/** + * The methods whose body carries a `params.name` / `params.uri` value the + * `Mcp-Name` header must mirror, and which body field supplies it (SEP-2243 + * § Standard Request Headers, `Required For` column). + */ +export const MCP_NAME_HEADER_SOURCE: Readonly> = { + 'tools/call': 'name', + 'prompts/get': 'name', + 'resources/read': 'uri' +}; + +/** + * SEP-2243 standard-header server-side validation, evaluated by the HTTP + * entry on a modern-classified request immediately after + * {@linkcode classifyInboundRequest} returns a modern route. + * + * Returns the `-32020` (`HeaderMismatch`) ladder rejection (HTTP `400`, + * `standard-header-validation` rung — the same shape + * {@linkcode classifyInboundRequest} already emits on the edge + * `era-classification` rung for the `MCP-Protocol-Version` and + * `Mcp-Method` *mismatch* cells) when: + * + * - the required `Mcp-Method` header is absent; + * - the required `Mcp-Name` header is absent on a `tools/call`, + * `prompts/get`, or `resources/read` request whose body carries the + * `params.name` / `params.uri` value the header mirrors; + * - the `Mcp-Name` header carries an invalid `=?base64?…?=` sentinel; or + * - the (decoded) `Mcp-Name` value disagrees with the body's + * `params.name` / `params.uri`. + * + * Returns `undefined` (pass) for notifications (the spec table reads + * "All requests"), for methods that have no `Mcp-Name` source, and when the + * headers agree with the body. Never enforced on legacy traffic — the entry + * only calls this on a modern route. + * + * Kept separate from {@linkcode classifyInboundRequest} so that a body-only + * call to the classifier (no headers passed) keeps routing a modern request + * unchanged: the classifier remains a pure body-primary router, and this + * function is the presence/`Mcp-Name` half of the standard-header rung the + * entry layers on top. + */ +export function validateStandardRequestHeaders(request: InboundHttpRequest, route: InboundModernRoute): InboundLadderRejection | undefined { + if (route.messageKind !== 'request') { + return undefined; + } + const method = route.message.method; + + if (request.mcpMethodHeader === undefined) { + return crossCheckMismatch( + 'method-header-missing', + '(missing)', + `the body names method ${method} but the required Mcp-Method header is absent`, + 'standard-header-validation' + ); + } + + // `method` is the JSON-RPC method string from the body — peer-controlled, + // so guard the plain-object lookup against `Object.prototype` collisions + // (`constructor`, `toString`, …) the same way the client-capability table + // lookup does. + const sourceField = Object.hasOwn(MCP_NAME_HEADER_SOURCE, method) ? MCP_NAME_HEADER_SOURCE[method] : undefined; + if (sourceField === undefined) { + return undefined; + } + const params = route.message.params as Record | undefined; + const sourceValue = params?.[sourceField]; + const bodyValue = typeof sourceValue === 'string' ? sourceValue : undefined; + + if (request.mcpNameHeader === undefined) { + // The header is required for these methods whenever the body carries + // the source value. A body without `params.name`/`params.uri` is a + // params-validation failure further down the ladder; this rung only + // answers the missing-header case it can observe. + if (bodyValue === undefined) { + return undefined; + } + return crossCheckMismatch( + 'name-header-missing', + '(missing)', + `the body carries params.${sourceField}="${bodyValue}" but the required Mcp-Name header is absent`, + 'standard-header-validation' + ); + } + + const decoded = decodeMcpParamValue(request.mcpNameHeader); + if (decoded === undefined) { + return crossCheckMismatch( + 'name-header-invalid-encoding', + request.mcpNameHeader, + 'the Mcp-Name header carries an invalid Base64 sentinel value', + 'standard-header-validation' + ); + } + if (bodyValue !== undefined && decoded !== bodyValue) { + return crossCheckMismatch( + 'name-header-mismatch', + request.mcpNameHeader, + `the body carries params.${sourceField}="${bodyValue}" but the Mcp-Name header names "${decoded}"`, + 'standard-header-validation' + ); + } + return undefined; +} + +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. + * + * Exported on the core internal barrel for the stdio serving entry, which + * applies the same precedence rule to a connection's opening message; not + * public API. + */ +export 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: JSONRPCRequest): InboundClassificationOutcome { + const params = body.params; + const method = body.method; + 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', message: body, 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: JSONRPCNotification): InboundClassificationOutcome { + const params = body.params; + const method = body.method; + 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', message: body, 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', + message: body, + classification: { era: 'modern', revision: headerVersion } + }; + } + return { kind: 'legacy', reason: 'notification', ...(headerVersion !== undefined && { requestedVersion: headerVersion }) }; +} + +/** + * Classifies one inbound HTTP request for dual-era serving. + * + * The body-primary predicate, evaluated once at the entry boundary: see the + * module documentation for the rules. Returns a routing outcome (`legacy` or + * `modern`) or a ladder rejection; it never throws. + */ +export function classifyInboundRequest(request: InboundHttpRequest): InboundClassificationOutcome { + if (request.httpMethod.toUpperCase() !== 'POST') { + // Body-less 2025-era session operations (and any other non-POST + // method): the modern era is POST-only. + return { kind: 'legacy', reason: 'http-method' }; + } + + const body = request.body; + if (Array.isArray(body)) { + return classifyBatch(body); + } + if (isJSONRPCResultResponse(body) || isJSONRPCErrorResponse(body)) { + // Posted responses are 2025-era session traffic (replies to + // server-initiated requests over a session); the modern era has no + // such channel. + return { kind: 'legacy', reason: 'response' }; + } + if (isPlainObject(body) && isJSONRPCRequest(body)) { + return classifyRequestBody(request, body); + } + if (isPlainObject(body) && isJSONRPCNotification(body)) { + return classifyNotificationBody(request, body); + } + return rejection( + 'jsonrpc-shape', + 'invalid-json-rpc-body', + 400, + new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Bad Request: the request body is not a valid JSON-RPC message'), + true + ); +} + +/* ------------------------------------------------------------------------ * + * Modern-only (strict) mapping of legacy routes + * ------------------------------------------------------------------------ */ + +/** + * The rejection a modern-only endpoint (no legacy serving configured) + * answers a legacy-classified request with. + * + * - Envelope-less requests (including `initialize`) are answered with the + * unsupported-protocol-version error carrying the endpoint's supported + * versions and echoing the version the request named (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/inputRequired.ts b/packages/core/src/shared/inputRequired.ts new file mode 100644 index 0000000000..dfd2cac05f --- /dev/null +++ b/packages/core/src/shared/inputRequired.ts @@ -0,0 +1,186 @@ +/** + * Authoring helpers for multi-round-trip requests (protocol revision + * 2026-07-28). + * + * A handler for one of the multi-round-trip methods (`tools/call`, + * `prompts/get`, `resources/read`) requests additional client input by + * returning an {@linkcode InputRequiredResult} instead of a final result. The + * helpers here build that return value and its embedded requests as NEUTRAL + * values; only the 2026-07-28 wire codec maps them to/from the wire (the + * 2025-era codec has no input-required vocabulary — on a 2025-era request the + * server seam fails such a return loudly; a handler that serves both eras + * branches on the served era and uses the push-style APIs toward 2025-era + * requests). + * + * There is no nominal brand: `resultType: 'input_required'` is the + * discriminator, and hand-built result literals are equally legal — the + * server seam re-checks the at-least-one rule for them. + */ +import { isInputRequiredResult } from '../types/guards.js'; +import type { + CreateMessageRequestParams, + ElicitRequestFormParams, + ElicitRequestURLParams, + ElicitResult, + InputRequest, + InputRequests, + InputRequiredResult, + InputResponses +} from '../types/types.js'; +import type { StandardSchemaV1 } from '../util/standardSchema.js'; + +/** The shape accepted by {@linkcode inputRequired}. */ +export interface InputRequiredSpec { + /** Embedded requests the client must fulfil before retrying. */ + inputRequests?: InputRequests; + /** Opaque server state echoed back verbatim by the client on retry. */ + requestState?: string; +} + +interface InputRequiredBuilder { + /** + * Builds the input-required return value for a multi-round-trip handler. + * + * At least one of `inputRequests` or `requestState` must be provided + * (spec: basic/patterns/mrtr, server requirements) — the builder throws a + * `TypeError` otherwise, and the server seam re-checks the same rule for + * hand-built results. + * + * `requestState` is opaque, server-minted state. It round-trips through + * the client and comes back as attacker-controlled input: a server that + * lets it influence authorization, resource access, or business logic + * MUST integrity-protect it (e.g. HMAC or AEAD) and MUST reject state + * that fails verification. The SDK does not do this for you. + */ + (spec: InputRequiredSpec): InputRequiredResult; + + /** Builds an embedded form-mode elicitation request (`elicitation/create`). */ + elicit(params: Omit & { mode?: 'form' }): InputRequest; + + /** + * Builds an embedded URL-mode elicitation request (`elicitation/create`). + * On the 2026-07-28 revision URL elicitation rides the multi-round-trip + * flow — the `-32042` error of earlier revisions never appears on this + * era's wire. The 2025-era `elicitationId` is not part of the 2026-07-28 + * URL-mode shape; correlation across retries is the server's own + * identifier inside `requestState`. + */ + elicitUrl(params: Omit): InputRequest; + + /** Builds an embedded sampling request (`sampling/createMessage`). */ + createMessage(params: CreateMessageRequestParams): InputRequest; + + /** Builds an embedded roots listing request (`roots/list`). */ + listRoots(): InputRequest; +} + +function buildInputRequired(spec: InputRequiredSpec): InputRequiredResult { + const hasInputRequests = spec.inputRequests !== undefined && Object.keys(spec.inputRequests).length > 0; + const hasRequestState = typeof spec.requestState === 'string'; + if (!hasInputRequests && !hasRequestState) { + throw new TypeError( + 'inputRequired() requires at least one of inputRequests (with at least one entry) or requestState ' + + '(spec: every InputRequiredResult MUST include at least one of the two)' + ); + } + return { + resultType: 'input_required', + ...(spec.inputRequests !== undefined && { inputRequests: spec.inputRequests }), + ...(spec.requestState !== undefined && { requestState: spec.requestState }) + }; +} + +/** + * Builder for the input-required return value of multi-round-trip handlers, + * with per-kind constructors for the embedded requests + * (`inputRequired.elicit`, `inputRequired.elicitUrl`, + * `inputRequired.createMessage`, `inputRequired.listRoots`). + * + * @example Write-once tool requesting confirmation + * ```ts + * server.registerTool('deploy', { inputSchema: z.object({ env: z.string() }) }, async ({ env }, ctx) => { + * const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + * if (!confirmed) { + * return inputRequired({ + * inputRequests: { + * confirm: inputRequired.elicit({ + * message: `Deploy to ${env}?`, + * requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + * }) + * } + * }); + * } + * return { content: [{ type: 'text', text: `deployed to ${env}` }] }; + * }); + * ``` + */ +export const inputRequired: InputRequiredBuilder = Object.assign(buildInputRequired, { + elicit(params: Omit & { mode?: 'form' }): InputRequest { + return { method: 'elicitation/create', params: { ...params, mode: 'form' } }; + }, + elicitUrl(params: Omit): InputRequest { + // The neutral ElicitRequestURLParams keeps `elicitationId` (it is required on the + // frozen 2025-11-25 revision); the 2026-07-28 in-band shape does not carry it. + return { method: 'elicitation/create', params: { ...params, mode: 'url' } as ElicitRequestURLParams }; + }, + createMessage(params: CreateMessageRequestParams): InputRequest { + return { method: 'sampling/createMessage', params }; + }, + listRoots(): InputRequest { + return { method: 'roots/list' }; + } +}); + +/** + * Reads the accepted content of a form-mode elicitation response from a + * retried request's `inputResponses` (`ctx.mcpReq.inputResponses`). + * + * Returns the response's `content` for `key` when the entry is an accepted + * elicitation result, and `undefined` otherwise (missing key, declined or + * cancelled elicitation, or a response of another kind). The values arrive + * from the client and are not re-validated here — treat them as untrusted + * input. + */ +export function acceptedContent = Record>( + responses: InputResponses | Record | undefined, + key: string +): T | undefined { + if (responses === undefined || typeof responses !== 'object' || responses === null) return undefined; + const entry = (responses as Record)[key]; + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return undefined; + const candidate = entry as Partial & Record; + if (candidate.action !== 'accept') return undefined; + if (candidate.content === undefined || typeof candidate.content !== 'object' || candidate.content === null) return undefined; + return candidate.content as T; +} + +/** + * Wraps a result schema so a request issued through `client.request()` / + * `ctx.mcpReq.send()` with `allowInputRequired: true` is typed as either the + * schema's result or an {@linkcode InputRequiredResult}. + * + * The manual multi-round-trip path: pass `{ allowInputRequired: true }` in the + * request options so an `input_required` response is handed back to the + * caller instead of being auto-fulfilled (or rejected), and wrap the result + * schema with `withInputRequired()` so the returned value is typed and + * validated correctly for both outcomes — `input_required` values pass + * through as-is, complete results validate against the wrapped schema. + */ +export function withInputRequired( + schema: S +): StandardSchemaV1 | InputRequiredResult> { + return { + '~standard': { + version: 1, + vendor: 'modelcontextprotocol', + validate: (value: unknown, options?: StandardSchemaV1.Options) => { + if (isInputRequiredResult(value)) { + return { value }; + } + return schema['~standard'].validate(value, options) as + | StandardSchemaV1.Result | InputRequiredResult> + | Promise | InputRequiredResult>>; + } + } + }; +} diff --git a/packages/core/src/shared/inputRequiredDriver.ts b/packages/core/src/shared/inputRequiredDriver.ts new file mode 100644 index 0000000000..7142f20175 --- /dev/null +++ b/packages/core/src/shared/inputRequiredDriver.ts @@ -0,0 +1,286 @@ +/** + * The multi-round-trip auto-fulfilment driver (protocol revision 2026-07-28). + * + * When a request to one of the multi-round-trip methods comes back as + * `input_required`, the driver fulfils the embedded input requests by + * dispatching them to the client's already-registered handlers (elicitation, + * sampling, roots — one generic engine, no per-feature API), then retries the + * original request with the collected `inputResponses` and a byte-exact echo + * of `requestState`, on a fresh request id, until the server returns a + * complete result or the round cap is exhausted. + * + * The driver is a LAYER OVER THE MANUAL PATH: each retry is issued with the + * same primitive a manual caller uses (`allowInputRequired` semantics — the + * retry hands back the next `input_required` payload instead of recursing), + * so the loop, the cap, and the pacing live in one place and disabling + * auto-fulfilment (`inputRequired.autoFulfill: false`) simply skips this + * module. Timeouts ride the EXISTING knobs: the per-leg `timeout` applies to + * every wire leg unchanged, and `maxTotalTimeout` bounds the whole flow by + * shrinking the budget passed to each leg — no new timer system. + */ +import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js'; +import { isInputRequiredResult } from '../types/guards.js'; +import type { Progress } from '../types/types.js'; + +/** + * Whether the multi-round-trip driver fulfils `input_required` results + * automatically when the consumer has not configured + * `inputRequired.autoFulfill`. The single switch for the default posture. + */ +export const DEFAULT_INPUT_REQUIRED_AUTO_FULFILL = true; + +/** + * Default round cap for the auto-fulfilment driver (both request legs and + * requestState-only legs count). Aligned with the other SDK client engines. + */ +export const DEFAULT_INPUT_REQUIRED_MAX_ROUNDS = 10; + +/** + * Fixed pacing applied before retrying a requestState-only (load-shedding) + * leg — a leg that carries no embedded input requests, so nothing slows the + * loop down naturally. Counted in the same round cap. + */ +export const REQUEST_STATE_ONLY_LEG_PACING_MS = 250; + +/** + * Multi-round-trip driver options (`inputRequired` on the client options bag). + */ +export interface InputRequiredOptions { + /** + * Fulfil `input_required` results automatically by dispatching the + * embedded requests to the registered handlers and retrying. + * + * Set to `false` for manual mode: an `input_required` response then + * surfaces as a typed error unless the individual call opts in with + * `allowInputRequired: true` (and, for typed results on the explicit + * schema path, `withInputRequired()`). + * + * @default true + */ + autoFulfill?: boolean; + + /** + * Maximum number of rounds (retries) the driver performs for a single + * call before failing with a typed + * {@linkcode SdkErrorCode.InputRequiredRoundsExceeded} error. + * + * @default 10 + */ + maxRounds?: number; +} + +/** The driver configuration with defaults applied. */ +export interface ResolvedInputRequiredDriverConfig { + autoFulfill: boolean; + maxRounds: number; +} + +export function resolveInputRequiredDriverConfig(options: InputRequiredOptions | undefined): ResolvedInputRequiredDriverConfig { + return { + autoFulfill: options?.autoFulfill ?? DEFAULT_INPUT_REQUIRED_AUTO_FULFILL, + maxRounds: options?.maxRounds ?? DEFAULT_INPUT_REQUIRED_MAX_ROUNDS + }; +} + +/** The discriminated `input_required` payload the wire codec hands to the driver. */ +export interface InputRequiredPayload { + inputRequests: Record; + requestState?: string; +} + +/** The slice of per-request options the driver consumes. */ +export interface InputRequiredDriverRequestOptions { + timeout?: number; + maxTotalTimeout?: number; + onprogress?: (progress: Progress) => void; +} + +/** Per-leg options the driver passes back to the funnel for each retry. */ +export interface InputRequiredRetryLegOptions { + timeout?: number; + maxTotalTimeout?: number; +} + +/** The hooks the engine provides to the driver. */ +export interface InputRequiredDriverHooks { + /** + * Dispatches one embedded input request to the locally registered handler + * and resolves with the bare response value. Rejections fail the whole + * call (typed errors: unknown kind, missing handler, handler failure). + * The signal is the per-round abort: when one sibling fails (or the + * caller aborts the originating call) the remaining dispatches are + * cancelled. + */ + dispatchInputRequest(key: string, entry: unknown, signal: AbortSignal): Promise; + + /** + * Re-issues the original request with the given params on a fresh request + * id, using the manual primitive: a complete result resolves validated, + * and a further `input_required` response resolves as the raw + * input-required value (never recursing into another driver run). + */ + retry(params: Record | undefined, legOptions: InputRequiredRetryLegOptions): Promise; +} + +/** Builds the retry params: original params + this round's responses + byte-exact requestState echo. */ +export function buildInputRequiredRetryParams( + originalParams: Record | undefined, + responses: Record | undefined, + requestState: string | undefined +): Record | undefined { + const hasResponses = responses !== undefined && Object.keys(responses).length > 0; + if (!hasResponses && requestState === undefined) { + return originalParams; + } + return { + ...originalParams, + ...(hasResponses && { inputResponses: responses }), + // Byte-exact echo: the opaque string is copied verbatim, never parsed. + // When the result carried no requestState, the retry carries none. + ...(requestState !== undefined && { requestState }) + }; +} + +/** + * Abortable delay: resolves after `ms`, or rejects with the signal's reason + * (wrapped in an `SdkError` when it isn't already one) if the signal aborts + * first. Aborting after resolution is a no-op. + */ +function sleep(ms: number, signal: AbortSignal | undefined): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason instanceof SdkError ? signal.reason : new SdkError(SdkErrorCode.RequestTimeout, String(signal.reason))); + return; + } + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = (): void => { + clearTimeout(timer); + reject(signal?.reason instanceof SdkError ? signal.reason : new SdkError(SdkErrorCode.RequestTimeout, String(signal?.reason))); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +/** + * A per-round abort linked to the caller's signal: the embedded sibling + * dispatches share it, so the first failure (or a caller abort) cancels the + * others instead of leaving them running. + */ +function linkedRoundAbort(outer: AbortSignal | undefined): { signal: AbortSignal; abort: (reason: unknown) => void; dispose: () => void } { + const controller = new AbortController(); + const onOuterAbort = (): void => controller.abort(outer?.reason); + outer?.addEventListener('abort', onOuterAbort, { once: true }); + if (outer?.aborted) controller.abort(outer.reason); + return { + signal: controller.signal, + abort: reason => controller.abort(reason), + dispose: () => outer?.removeEventListener('abort', onOuterAbort) + }; +} + +/** + * Runs the auto-fulfilment loop for one originating request. Resolves with + * the final complete result (already validated by the retry leg) or rejects + * with a typed error. + * + * `flowStartedAt` is the timestamp the ORIGINAL request was issued at (not + * when the driver started): `maxTotalTimeout` bounds the whole flow, so the + * first wire leg counts against the budget too. When omitted, accounting + * starts when the driver starts. + */ +export async function runInputRequiredDriver(args: { + config: ResolvedInputRequiredDriverConfig; + method: string; + originalParams: Record | undefined; + firstPayload: InputRequiredPayload; + requestOptions: InputRequiredDriverRequestOptions; + hooks: InputRequiredDriverHooks; + /** The originating call's abort signal — chains through every round and the pacing sleep. */ + signal?: AbortSignal; + flowStartedAt?: number; +}): Promise { + const { config, method, originalParams, requestOptions, hooks, signal } = args; + const startedAt = args.flowStartedAt ?? Date.now(); + let payload = args.firstPayload; + let round = 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + round += 1; + if (round > config.maxRounds) { + throw new SdkError( + SdkErrorCode.InputRequiredRoundsExceeded, + `Multi-round-trip request '${method}' still required input after ${config.maxRounds} rounds (inputRequired.maxRounds)`, + { + rounds: config.maxRounds, + lastResult: { + inputRequests: payload.inputRequests, + ...(payload.requestState !== undefined && { requestState: payload.requestState }) + } + } + ); + } + + // Surface the round as synthetic progress: long interactive flows stay + // observable, and consumers composing `resetTimeoutOnProgress`-style + // watchdogs around the call see liveness instead of silence. + requestOptions.onprogress?.({ progress: round, message: `Fulfilling input required by '${method}' (round ${round})` }); + + const entries = Object.entries(payload.inputRequests ?? {}); + let responses: Record | undefined; + if (entries.length > 0) { + // Fulfil concurrently (the embedded requests are independent); a + // single failure fails the call AND aborts the siblings via the + // linked per-round signal so they do not keep running. + const round = linkedRoundAbort(signal); + try { + const fulfilled = await Promise.all( + entries.map(async ([key, entry]) => { + try { + return [key, await hooks.dispatchInputRequest(key, entry, round.signal)] as const; + } catch (error) { + round.abort(error); + throw error; + } + }) + ); + responses = Object.fromEntries(fulfilled); + } finally { + round.dispose(); + } + } else { + // requestState-only (load-shedding) leg: fixed pacing so the loop + // never hot-spins; counted in the same round cap. The sleep + // honors the caller's abort signal. + await sleep(REQUEST_STATE_ONLY_LEG_PACING_MS, signal); + } + + const legOptions: InputRequiredRetryLegOptions = { + ...(requestOptions.timeout !== undefined && { timeout: requestOptions.timeout }) + }; + if (requestOptions.maxTotalTimeout !== undefined) { + const totalElapsed = Date.now() - startedAt; + const remaining = requestOptions.maxTotalTimeout - totalElapsed; + if (remaining <= 0) { + throw new SdkError(SdkErrorCode.RequestTimeout, 'Maximum total timeout exceeded', { + maxTotalTimeout: requestOptions.maxTotalTimeout, + totalElapsed + }); + } + legOptions.maxTotalTimeout = remaining; + } + + const result = await hooks.retry(buildInputRequiredRetryParams(originalParams, responses, payload.requestState), legOptions); + if (isInputRequiredResult(result)) { + payload = { + inputRequests: result.inputRequests ?? {}, + ...(result.requestState !== undefined && { requestState: result.requestState }) + }; + continue; + } + return result; + } +} diff --git a/packages/core/src/shared/inputRequiredEngine.ts b/packages/core/src/shared/inputRequiredEngine.ts new file mode 100644 index 0000000000..b98ad54653 --- /dev/null +++ b/packages/core/src/shared/inputRequiredEngine.ts @@ -0,0 +1,243 @@ +/** + * The multi-round-trip auto-fulfilment ENGINE (protocol revision 2026-07-28): + * the wiring between the protocol layer's response funnel, the + * already-registered input handlers, and the pure {@link runInputRequiredDriver} + * loop. The engine is what the `Client` plugs into the funnel's + * `_resolveNonCompleteResult` extension point — `Protocol` itself only knows + * the input-required branch exists. + * + * Relocated here so the shared `Protocol` base stays generic: the only + * MRTR-specific code that remains in `protocol.ts` is the irreducible + * input-required branch in the response path, the type surface (the + * `allowInputRequired` request option and the `inputResponses`/`requestState`/ + * `droppedInputResponseKeys` context fields), and the named extension point. + */ +import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js'; +import type { InputRequiredResult, JSONRPCRequest, RequestMeta, Result } from '../types/types.js'; +import type { StandardSchemaV1 } from '../util/standardSchema.js'; +import type { WireCodec } from '../wire/codec.js'; +import type { + InputRequiredDriverHooks, + InputRequiredPayload, + InputRequiredRetryLegOptions, + ResolvedInputRequiredDriverConfig +} from './inputRequiredDriver.js'; +import { runInputRequiredDriver } from './inputRequiredDriver.js'; +import type { BaseContext, NonCompleteResultFlow, RequestOptions } from './protocol.js'; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Splits a retried request's `inputResponses` map into the BARE response + * entries the spec defines and everything else. The spec's embedded responses + * are the bare result objects (an `ElicitResult`, `CreateMessageResult`, or + * `ListRootsResult`); a wrapped `{method, result}` envelope (a shape some + * peers emit) is never accepted as a response — its key is recorded so the + * handler can re-issue the corresponding input request. + */ +export function partitionInputResponses(inputResponses: unknown): { accepted: Record; droppedKeys: string[] } { + const accepted: Record = {}; + const droppedKeys: string[] = []; + if (!isPlainObject(inputResponses)) { + return { accepted, droppedKeys }; + } + for (const [key, entry] of Object.entries(inputResponses)) { + // Bare responses never carry `method` or `result` members — both are + // the signature of the wrapped (JSON-RPC-shaped) form. + if (!isPlainObject(entry) || 'method' in entry || 'result' in entry) { + droppedKeys.push(key); + continue; + } + accepted[key] = entry; + } + return { accepted, droppedKeys }; +} + +/** + * Related send/notify are unavailable inside an embedded input-request + * handler: the request is fulfilled locally by the multi-round-trip driver, + * so there is no live peer request to relate messages to. + */ +function relatedMessagingUnavailable(member: string): never { + throw new SdkError( + SdkErrorCode.SendFailed, + `ctx.mcpReq.${member} is not available while fulfilling an embedded input request: ` + + `the request is fulfilled locally and has no related peer request` + ); +} + +/** + * The synthesized {@linkcode BaseContext} for an embedded input request: the + * id is the `inputRequests` key (correlation only — it is not a JSON-RPC + * message id), the supplied abort signal chains the originating call's signal + * through, and related `send`/`notify` are unavailable because there is no + * live peer request to relate them to. + */ +export function synthesizeInputRequestContext( + key: string, + method: string, + params: Record | undefined, + signal: AbortSignal, + sessionId: string | undefined +): BaseContext { + return { + sessionId, + mcpReq: { + id: key, + method, + _meta: params?.['_meta'] as RequestMeta | undefined, + signal, + send: (() => relatedMessagingUnavailable('send')) as BaseContext['mcpReq']['send'], + notify: () => relatedMessagingUnavailable('notify') + } + }; +} + +/** + * Hooks the engine needs from the consuming role class (the `Client`): how to + * look up a registered handler and how to enrich a base context. + */ +export interface InputRequiredEngineHost { + /** The handler registered for the given method, or `undefined`. */ + getRequestHandler(method: string): ((request: JSONRPCRequest, ctx: unknown) => Promise) | undefined; + /** Builds the role-specific context from a {@linkcode BaseContext}. */ + buildContext(baseCtx: BaseContext): unknown; + /** The transport's session identifier, when there is one. */ + sessionId: string | undefined; +} + +/** + * Dispatches one embedded (de-JSON-RPC'd) input request to the locally + * registered handler for its method and resolves with the bare response. + * + * The handler runs through the same stored handler chain as a wire request + * (including role-specific validation installed by `_wrapHandler`), with a + * synthesized context (see {@link synthesizeInputRequestContext}). + */ +export async function dispatchInputRequest( + host: InputRequiredEngineHost, + codec: WireCodec, + key: string, + entry: unknown, + signal: AbortSignal +): Promise { + if (!isPlainObject(entry) || typeof entry['method'] !== 'string') { + throw new SdkError( + SdkErrorCode.InvalidResult, + `Invalid input request '${key}': each inputRequests entry must be an embedded request object with a method`, + { key } + ); + } + const method = entry['method']; + if (!codec.hasInputRequestMethod(method)) { + throw new SdkError( + SdkErrorCode.InvalidResult, + `Invalid input request '${key}': '${method}' is not an embedded request the ${codec.era} revision defines ` + + `(expected elicitation/create, sampling/createMessage, or roots/list)`, + { key, method } + ); + } + const handler = host.getRequestHandler(method); + if (handler === undefined) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Cannot fulfil input request '${key}': no handler is registered for '${method}' on this client. ` + + `Declare the corresponding capability and register a handler, or handle input_required results manually.`, + { key, method } + ); + } + + const params = isPlainObject(entry['params']) ? (entry['params'] as Record) : undefined; + const synthesizedRequest: JSONRPCRequest = { + jsonrpc: '2.0', + id: key, + method, + ...(params !== undefined && { params }) + }; + const ctx = host.buildContext(synthesizeInputRequestContext(key, method, params, signal, host.sessionId)); + return await handler(synthesizedRequest, ctx); +} + +/** + * Builds the per-retry-leg {@linkcode RequestOptions} from the originating + * call's options. + * + * Only the fields that are correct to apply to every leg carry over (a + * deliberate whitelist): the per-leg `timeout`, the (shrinking) total budget + * `maxTotalTimeout`, the caller's `onprogress`/`resetTimeoutOnProgress`, and + * the caller's abort `signal`. Everything else — in particular + * `relatedRequestId`, `resumptionToken`, and `onresumptiontoken` — is scoped + * to the originating wire leg and is NOT inherited by retries. + */ +export function buildRetryLegRequestOptions(options: RequestOptions | undefined, legOptions: InputRequiredRetryLegOptions): RequestOptions { + return { + ...(options?.signal !== undefined && { signal: options.signal }), + ...(options?.onprogress !== undefined && { onprogress: options.onprogress }), + ...(options?.resetTimeoutOnProgress !== undefined && { resetTimeoutOnProgress: options.resetTimeoutOnProgress }), + // Per-request HTTP headers (SEP-2243 `Mcp-Param-*`) carry over: the + // retry's `arguments` are byte-identical to the originating leg (the + // driver only adds `inputResponses`/`requestState`), so the param + // headers built for the first leg remain correct for every retry leg. + ...(options?.headers !== undefined && { headers: options.headers }), + ...(legOptions.timeout !== undefined && { timeout: legOptions.timeout }), + ...(legOptions.maxTotalTimeout !== undefined && { maxTotalTimeout: legOptions.maxTotalTimeout }), + // The driver re-enters the funnel with the manual primitive: a further + // input_required answer is handed back to the loop instead of + // recursing into another driver run (the round cap is global to the + // flow). + allowInputRequired: true + }; +} + +/** + * Runs the auto-fulfilment flow for one originating request whose response + * came back as `input_required`: builds the driver hooks (embedded-request + * dispatch + retry through the funnel) and hands them to + * {@link runInputRequiredDriver}. Resolves with the final complete result + * (already validated by the retry leg) or rejects with a typed error. + */ +export function runInputRequiredFlow( + host: InputRequiredEngineHost, + config: ResolvedInputRequiredDriverConfig, + decoded: { inputRequests: Record; requestState?: string }, + flow: NonCompleteResultFlow +): Promise { + const { codec, request, options, flowStartedAt } = flow; + const firstPayload: InputRequiredPayload = { + inputRequests: decoded.inputRequests, + ...(decoded.requestState !== undefined && { requestState: decoded.requestState }) + }; + const hooks: InputRequiredDriverHooks = { + dispatchInputRequest: (key, entry, signal) => dispatchInputRequest(host, codec, key, entry, signal), + retry: (params, legOptions) => flow.retry(params, buildRetryLegRequestOptions(options, legOptions)) + }; + return runInputRequiredDriver({ + config, + method: request.method, + originalParams: request.params, + firstPayload, + flowStartedAt, + signal: options?.signal, + requestOptions: { + ...(options?.timeout !== undefined && { timeout: options.timeout }), + ...(options?.maxTotalTimeout !== undefined && { maxTotalTimeout: options.maxTotalTimeout }), + ...(options?.onprogress !== undefined && { onprogress: options.onprogress }) + }, + hooks + }); +} + +/** + * Builds the manual-mode {@linkcode InputRequiredResult} value from the + * codec's decoded payload — what an `allowInputRequired: true` caller + * receives instead of the auto-fulfilled complete result. + */ +export function manualInputRequiredValue(decoded: { inputRequests: Record; requestState?: string }): InputRequiredResult { + return { + resultType: 'input_required', + inputRequests: decoded.inputRequests as InputRequiredResult['inputRequests'], + ...(decoded.requestState !== undefined && { requestState: decoded.requestState }) + }; +} diff --git a/packages/core/src/shared/mcpParamHeaders.ts b/packages/core/src/shared/mcpParamHeaders.ts new file mode 100644 index 0000000000..87884a5408 --- /dev/null +++ b/packages/core/src/shared/mcpParamHeaders.ts @@ -0,0 +1,417 @@ +/** + * SEP-2243 `Mcp-Param-*` header codec (protocol revision 2026-07-28). + * + * Pure functions for the custom-header half of SEP-2243: scanning a tool's + * `inputSchema` for `x-mcp-header` declarations, encoding argument values into + * `Mcp-Param-{Name}` HTTP headers (with the `=?base64?…?=` sentinel for values + * that cannot be safely represented as plain ASCII field values), decoding + * those headers, and validating that the headers a request carries match the + * argument values in its body. + * + * The standard-header half (`MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name`) + * lives with the inbound classifier — this module is the custom-header half + * only, and it consumes the same `-32020` (`HeaderMismatch`) emission shape the + * classifier established for header/body cross-check failures. + * + * Spec text at the implementation's spec pin: + * - draft/basic/transports/streamable-http.mdx § "Custom Headers from Tool Parameters" + * (constraints, value encoding, the 5-step client algorithm, the + * server-behavior table, the `400` + `-32020` rejection) + * - draft/server/tools.mdx § "x-mcp-header" (the schema-extension property and + * its constraints) + */ +import type { InboundLadderRejection } from './inboundClassification.js'; +import { HEADER_MISMATCH_ERROR_CODE } from './inboundClassification.js'; + +/* ------------------------------------------------------------------------ * + * Declaration scan + * ------------------------------------------------------------------------ */ + +/** The fixed prefix every custom-parameter header carries. */ +export const MCP_PARAM_HEADER_PREFIX = 'Mcp-Param-'; + +/** The schema-extension property name a tool's `inputSchema` carries. */ +export const X_MCP_HEADER_KEY = 'x-mcp-header'; + +/** + * One `x-mcp-header` declaration found inside a tool's `inputSchema`. + * + * `path` is the property path from the arguments root (the spec permits + * declarations at any nesting depth under `properties`); `headerName` is the + * `{Name}` portion as declared (case preserved for emission; comparison is + * case-insensitive); `type` is the JSON Schema `type` of the declaring + * property. + */ +export interface XMcpHeaderDeclaration { + path: readonly string[]; + headerName: string; + type: string; +} + +/** The result of scanning a tool's `inputSchema` for `x-mcp-header` declarations. */ +export type XMcpHeaderScanResult = { valid: true; declarations: readonly XMcpHeaderDeclaration[] } | { valid: false; reason: string }; + +/** + * RFC 9110 §5.1 `token` syntax (`1*tchar`). Rejects empty, space, control + * characters (including CR/LF), and the listed delimiters. + */ +const RFC9110_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +/** + * JSON Schema `type` values the spec admits on an `x-mcp-header` property. + * + * The spec text names `integer`, `string`, `boolean` and explicitly excludes + * `number`. The published conformance referee at the pinned release ships its + * `http-custom-headers` scenario with two `type: "number"` `x-mcp-header` + * parameters and expects the client to mirror them, so `number` is accepted + * here so that the conformance gate passes; the discrepancy is tracked + * upstream. Everything else (`object`, `array`, `null`, absent) is rejected. + */ +const PERMITTED_X_MCP_HEADER_TYPES: ReadonlySet = new Set(['string', 'integer', 'boolean', 'number']); + +/** + * Scan a tool's JSON-serialized `inputSchema` for `x-mcp-header` declarations + * and validate every constraint the spec places on them. Returns either the + * collected declarations (possibly empty) or the first violated constraint. + * + * The walk descends through `properties` at any depth (the spec's "any nesting + * depth" clause). The static-reachability MUST is enforced as a structural + * sweep: every position the chain MUST NOT pass through (`items`/ + * `additionalProperties`, `oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`, + * `$defs`, `$ref` targets within `$defs`) is visited too, and an + * `x-mcp-header` found anywhere on that path invalidates the schema — "an + * annotation anywhere else makes the tool definition invalid". + */ +export function scanXMcpHeaderDeclarations(inputSchema: unknown): XMcpHeaderScanResult { + const declarations: XMcpHeaderDeclaration[] = []; + const seenLower = new Map(); + + const visit = (node: unknown, path: readonly string[], reachable: boolean): string | undefined => { + if (node === null || typeof node !== 'object') return undefined; + const schema = node as Record; + + if (X_MCP_HEADER_KEY in schema) { + if (!reachable || path.length === 0) { + return `${pathName(path)}: x-mcp-header is only permitted on properties statically reachable via a chain of 'properties' keys (not under items, additionalProperties, oneOf/anyOf/allOf/not, if/then/else, or $ref)`; + } + const raw = schema[X_MCP_HEADER_KEY]; + if (typeof raw !== 'string' || raw.length === 0) { + return `${pathName(path)}: x-mcp-header MUST be a non-empty string`; + } + if (!RFC9110_TOKEN.test(raw)) { + return `${pathName(path)}: x-mcp-header '${raw}' is not a valid RFC 9110 token (no spaces, control characters or HTTP delimiters)`; + } + const type = typeof schema.type === 'string' ? schema.type : undefined; + if (type === undefined || !PERMITTED_X_MCP_HEADER_TYPES.has(type)) { + return `${pathName(path)}: x-mcp-header is only permitted on primitive-typed properties (string, integer, boolean); got ${type ?? ''}`; + } + const lower = raw.toLowerCase(); + const prior = seenLower.get(lower); + if (prior !== undefined) { + return `x-mcp-header '${raw}' is not case-insensitively unique (also declared as '${prior}')`; + } + seenLower.set(lower, raw); + declarations.push({ path, headerName: raw, type }); + } + + const properties = schema.properties; + if (properties !== null && typeof properties === 'object') { + for (const [key, child] of Object.entries(properties as Record)) { + const fault = visit(child, [...path, key], reachable); + if (fault !== undefined) return fault; + } + } + // Static-reachability sweep: descend the keywords the chain MUST NOT + // pass through with `reachable: false` so an annotation under any of + // them is reported (rather than silently ignored). `$defs` covers + // `$ref`-within-`$defs` — chasing arbitrary `$ref` URIs is out of scope. + for (const k of NON_REACHABLE_SUBSCHEMA_KEYWORDS) { + const sub = schema[k]; + if (sub === undefined) continue; + const branches: unknown[] = Array.isArray(sub) + ? sub + : sub !== null && typeof sub === 'object' && OBJECT_VALUED_SUBSCHEMA_KEYWORDS.has(k) + ? Object.values(sub as Record) + : [sub]; + for (const branch of branches) { + const fault = visit(branch, [...path, `<${k}>`], false); + if (fault !== undefined) return fault; + } + } + return undefined; + }; + + const fault = visit(inputSchema, [], true); + return fault === undefined ? { valid: true, declarations } : { valid: false, reason: fault }; +} + +/** + * JSON Schema keywords whose subschemas the SEP-2243 static-reachability + * constraint excludes from the `properties`-only chain. An `x-mcp-header` + * found under any of these invalidates the tool definition. + */ +const NON_REACHABLE_SUBSCHEMA_KEYWORDS = [ + 'items', + 'prefixItems', + 'contains', + 'additionalProperties', + 'unevaluatedProperties', + 'unevaluatedItems', + 'propertyNames', + 'patternProperties', + 'dependentSchemas', + 'oneOf', + 'anyOf', + 'allOf', + 'not', + 'if', + 'then', + 'else', + '$defs', + 'definitions' +] as const; + +/** + * Subschema-carrying keywords whose value is a `name → subschema` object + * (not a single subschema or array of subschemas). The visit branches over + * `Object.values()` for these. + */ +const OBJECT_VALUED_SUBSCHEMA_KEYWORDS: ReadonlySet = new Set(['patternProperties', 'dependentSchemas', '$defs', 'definitions']); + +function pathName(path: readonly string[]): string { + return path.length === 0 ? '' : path.join('.'); +} + +/* ------------------------------------------------------------------------ * + * Value encoding + * ------------------------------------------------------------------------ */ + +const BASE64_SENTINEL_PREFIX = '=?base64?'; +const BASE64_SENTINEL_SUFFIX = '?='; +// RFC 4648 §4, padding required (the spec's encoding-examples table and the +// conformance referee's invalid-padding cell both require canonical padding). +const BASE64_CANONICAL = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; +// Strict decimal — gates the numeric comparison in `validateMcpParamHeaders` +// so `Number()` never sees the looser forms it would otherwise accept +// (`'0x1a'`, `' 42 '`, `'1e3'`). +const CANONICAL_DECIMAL = /^-?\d+(\.\d+)?$/; + +/** + * Convert a primitive argument value to its string representation per the + * spec's type-conversion rules: strings pass through, integers and numbers + * become their decimal string, booleans become lowercase `'true'` / `'false'`. + * Non-finite numbers and integers outside the safe range are refused (the + * caller treats `undefined` as "do not emit a header for this value"). + */ +export function mcpParamPrimitiveToString(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'number') { + if (!Number.isFinite(value)) return undefined; + if (Number.isInteger(value) && !Number.isSafeInteger(value)) return undefined; + return String(value); + } + return undefined; +} + +/** + * `true` when `s` cannot be safely represented as a plain ASCII HTTP field + * value per RFC 9110 §5.5: it contains a byte outside `0x20–0x7E` / `0x09`, it + * has leading or trailing whitespace (which field parsing strips), or it + * already matches the Base64 sentinel pattern (the spec's "to avoid ambiguity" + * rule). + */ +function needsBase64(s: string): boolean { + if (s.length === 0) return true; + if (s.startsWith(BASE64_SENTINEL_PREFIX) && s.endsWith(BASE64_SENTINEL_SUFFIX)) return true; + if (s !== s.trim()) return true; + for (let i = 0; i < s.length; i++) { + const c = s.codePointAt(i)!; + // Visible ASCII 0x21–0x7E, plus space 0x20 and horizontal tab 0x09; a + // tab is only safe when it is interior whitespace (the trim() check + // above already covered leading/trailing). + if (c === 0x09 || (c >= 0x20 && c <= 0x7e)) continue; + return true; + } + return false; +} + +function utf8ToBase64(s: string): string { + const bytes = new TextEncoder().encode(s); + let bin = ''; + for (const b of bytes) bin += String.fromCodePoint(b); + return btoa(bin); +} + +function base64ToUtf8(b64: string): string { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.codePointAt(i)!; + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); +} + +/** + * Encode a string value as an HTTP field value per the spec's value-encoding + * rules: a value that is already a safe plain-ASCII field value is passed + * through unchanged; anything else is wrapped as `=?base64?{b64-of-utf8}?=`. + */ +export function encodeMcpParamValue(value: string): string { + return needsBase64(value) ? `${BASE64_SENTINEL_PREFIX}${utf8ToBase64(value)}${BASE64_SENTINEL_SUFFIX}` : value; +} + +/** + * Decode an `Mcp-Param-*` header value: when it carries the Base64 sentinel, + * the payload is decoded as UTF-8; otherwise the value is returned as-is. + * Returns `undefined` when the sentinel is present but the payload is not + * canonical Base64 (or not valid UTF-8) — the spec requires servers to reject + * such values. + */ +export function decodeMcpParamValue(value: string): string | undefined { + if (!(value.startsWith(BASE64_SENTINEL_PREFIX) && value.endsWith(BASE64_SENTINEL_SUFFIX))) { + return value; + } + const b64 = value.slice(BASE64_SENTINEL_PREFIX.length, value.length - BASE64_SENTINEL_SUFFIX.length); + if (!BASE64_CANONICAL.test(b64)) return undefined; + try { + return base64ToUtf8(b64); + } catch { + return undefined; + } +} + +/* ------------------------------------------------------------------------ * + * Client-side header construction (the 5-step MUST algorithm, steps 3–5) + * ------------------------------------------------------------------------ */ + +function valueAtPath(root: unknown, path: readonly string[]): unknown { + let node: unknown = root; + for (const key of path) { + if (node === null || typeof node !== 'object') return undefined; + node = (node as Record)[key]; + } + return node; +} + +/** + * Build the `Mcp-Param-{Name}` headers for one `tools/call` from a scan of the + * tool's `inputSchema` and the call's `arguments`. A declaration whose value is + * `null` or absent in `arguments` is omitted (the spec's "client MUST omit the + * header" rows); a value that is not a primitive of the declared kind is + * omitted rather than emitted malformed. + */ +export function buildMcpParamHeaders( + declarations: readonly XMcpHeaderDeclaration[], + args: Record | undefined +): Record { + const out: Record = {}; + for (const decl of declarations) { + const raw = valueAtPath(args, decl.path); + if (raw === undefined || raw === null) continue; + const stringValue = mcpParamPrimitiveToString(raw); + if (stringValue === undefined) continue; + out[`${MCP_PARAM_HEADER_PREFIX}${decl.headerName}`] = encodeMcpParamValue(stringValue); + } + return out; +} + +/* ------------------------------------------------------------------------ * + * Server-side validation + * ------------------------------------------------------------------------ */ + +/** + * The header/body comparison the server performs at tool-resolution time. + * + * For each `x-mcp-header` declaration on the named tool: when the body + * `arguments` carries a value, the matching `Mcp-Param-{Name}` header MUST be + * present and decode to an equal value; when the body value is `null` or + * absent the server MUST NOT expect the header (a present header is ignored). + * A sentinel-carrying header whose payload is not canonical Base64 / valid + * UTF-8 is rejected as invalid characters. + * + * Integer-typed declarations are compared numerically (the spec's SHOULD — + * `42.0` and `42` are equal); everything else is compared as decoded strings. + * + * Returns `undefined` when every check passes, or an + * {@linkcode InboundLadderRejection} carrying the same `-32020` + * (`HeaderMismatch`) shape the inbound classifier emits for the + * standard-header cross-checks — `400 Bad Request` with the disagreeing pair + * in `data.mismatch`. + */ +export function validateMcpParamHeaders( + declarations: readonly XMcpHeaderDeclaration[], + args: Record | undefined, + headers: Headers +): InboundLadderRejection | undefined { + for (const decl of declarations) { + const headerKey = `${MCP_PARAM_HEADER_PREFIX}${decl.headerName}`; + const headerValue = headers.get(headerKey); + const bodyRaw = valueAtPath(args, decl.path); + + if (bodyRaw === undefined || bodyRaw === null) { + // Server MUST NOT expect the header for a null/absent value. + continue; + } + const bodyString = mcpParamPrimitiveToString(bodyRaw); + if (bodyString === undefined) { + // Body carries a non-primitive where the schema declares one; + // params validation owns that fault. Skip the header check. + continue; + } + if (headerValue === null) { + return paramHeaderMismatchRejection( + 'param-header-missing', + headerKey, + `the body carries ${pathName(decl.path)}=${JSON.stringify(bodyRaw)} but the ${headerKey} header is absent` + ); + } + const decoded = decodeMcpParamValue(headerValue); + if (decoded === undefined) { + return paramHeaderMismatchRejection( + 'param-header-invalid-encoding', + headerKey, + `the ${headerKey} header carries an invalid Base64 sentinel value` + ); + } + // Integer/number-typed declarations compare numerically (the spec's + // SHOULD — `42.0` and `42` are equal). The strict-decimal gate is + // applied to the *header* side only (so `'0x1a'`, `' 42 '`, `'1e3'` + // etc. never coerce); the body side is gated on being an actual JS + // number — `String(0.0000001) === '1e-7'` would fail the regex even + // though the value is perfectly canonical. A non-numeric body + // primitive (e.g. `'abc'` where the schema declares `integer`) is a + // body-vs-schema fault that params validation owns; fall back to + // string comparison and let dispatch emit `-32602` instead so an + // identical non-numeric pair never reports a mismatch. + const numericComparable = + (decl.type === 'integer' || decl.type === 'number') && CANONICAL_DECIMAL.test(decoded) && typeof bodyRaw === 'number'; + const equal = numericComparable ? Number(decoded) === bodyRaw : decoded === bodyString; + if (!equal) { + return paramHeaderMismatchRejection( + 'param-header-mismatch', + headerKey, + `the ${headerKey} header decodes to ${JSON.stringify(decoded)} but the body carries ${pathName(decl.path)}=${JSON.stringify(bodyRaw)}` + ); + } + } + return undefined; +} + +/** + * Build the `-32020` (`HeaderMismatch`) rejection for an `Mcp-Param-*` + * disagreement. Same shape as the inbound classifier's standard-header + * cross-check mismatch (HTTP `400`, `data.mismatch` naming the disagreeing + * pair, `settled: true`); only the rung differs because this check runs at the + * pre-dispatch step against a known tool's schema rather than at the edge. + */ +export function paramHeaderMismatchRejection(cell: string, header: string, body: string): InboundLadderRejection { + return { + kind: 'reject', + rung: 'param-header-validation', + cell, + httpStatus: 400, + code: HEADER_MISMATCH_ERROR_CODE, + message: `Bad Request: the request headers and body disagree: ${body}`, + data: { mismatch: { header, body } }, + settled: true + }; +} diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 16d2181018..b141c98f03 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -9,6 +9,7 @@ import type { ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, + HandlerResultTypeMap, JSONRPCErrorResponse, JSONRPCNotification, JSONRPCRequest, @@ -24,6 +25,7 @@ import type { Request, RequestId, RequestMeta, + RequestMetaEnvelope, RequestMethod, RequestTypeMap, Result, @@ -31,19 +33,24 @@ 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, MODERN_WIRE_REVISION } from '../wire/codec.js'; +import { manualInputRequiredValue, partitionInputResponses } from './inputRequiredEngine.js'; import type { Transport, TransportSendOptions } from './transport.js'; /** @@ -56,8 +63,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} */ @@ -117,10 +126,46 @@ export type RequestOptions = { * Maximum total time (in milliseconds) to wait for a response. * If exceeded, an {@linkcode SdkError} with code {@linkcode SdkErrorCode.RequestTimeout} will be raised, regardless of progress notifications. * If not specified, there is no maximum total timeout. + * + * For multi-round-trip requests fulfilled by the auto-fulfilment driver + * (protocol revision 2026-07-28), the budget bounds the WHOLE flow: every + * retry leg is given only the time remaining. */ maxTotalTimeout?: number; + + /** + * Manual multi-round-trip mode for this call (protocol revision + * 2026-07-28): when the response is an `input_required` result, hand it + * back to the caller instead of auto-fulfilling it (or raising a typed + * error). The resolved value is the neutral input-required shape + * (`resultType: 'input_required'`, `inputRequests?`, `requestState?`); + * wrap the result schema with `withInputRequired()` on the explicit + * schema path to type both outcomes. The caller is then responsible for + * gathering the requested input and retrying the original request with + * `inputResponses` / `requestState` params and a fresh request. + * + * Default: `false`. + */ + allowInputRequired?: boolean; } & TransportSendOptions; +/** + * Flow context handed to {@linkcode Protocol._resolveNonCompleteResult}: the + * originating request, its options, the wire codec that decoded the response, + * the timestamp the originating leg was issued at (for whole-flow timeout + * accounting), and a `retry` closure that re-enters the request funnel with + * fresh params on a fresh request id. + */ +export interface NonCompleteResultFlow { + codec: WireCodec; + request: Request; + resultSchema: T; + options: RequestOptions | undefined; + flowStartedAt: number; + /** Re-issue the originating request with the given params and per-leg options. */ + retry(params: Record | undefined, legOptions: RequestOptions): Promise; +} + /** * Options that can be given per notification. */ @@ -131,6 +176,113 @@ 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 }; +} + +/** + * Standard Schema adapter over the era codec's `validateResult` function (the + * function-only WireCodec contract exposes no schema objects). Used by the + * spec-method `request()` overload so the request funnel keeps a single + * `StandardSchemaV1`-shaped validation seam for both spec and explicit-schema + * paths. + * + * Returns `undefined` when the method has no result entry on this era's + * registry — the caller maps that to the synchronous "pass a result schema" + * TypeError, exactly matching the pre-function-only behavior the + * typedMapAlignment suite pins (the result map deliberately excludes the + * `tasks/*` methods, so the spec-method overload refuses them up front). + */ +function codecResultValidator(codec: WireCodec, method: string): StandardSchemaV1 | undefined { + // Probe for result-registry membership through the function-only + // contract: a `not-in-era` outcome means no result entry for this method + // (the probe value is irrelevant — every spec result schema rejects + // `undefined`, so a method with an entry returns `invalid`, never + // `not-in-era`). + const probe = codec.validateResult(method, undefined); + if (!probe.ok && probe.reason === 'not-in-era') return undefined; + return { + '~standard': { + version: 1, + vendor: 'mcp-wire-codec', + validate(value: unknown): StandardSchemaV1.Result { + const outcome = codec.validateResult(method, value); + if (outcome.ok) return { value: outcome.value }; + return { issues: [{ message: outcome.reason === 'invalid' ? outcome.message : `not-in-era: ${method}` }] }; + } + } + }; +} + /** * Base context provided to all request handlers. */ @@ -155,10 +307,59 @@ 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. Entries are the BARE response objects keyed by the + * identifiers the server assigned in `inputRequests`; entries that do + * not look like bare responses (e.g. a `{method, result}` wrapper) + * are dropped and their keys recorded in `droppedInputResponseKeys`. + * + * The values arrive from the client and are NOT validated by the SDK + * — treat them as untrusted input. + */ + inputResponses?: Record; + + /** + * Keys of `inputResponses` entries the SDK dropped because they were + * not bare response objects (for example the wrapped `{method, + * result}` shape some peers emit). Surfaced so a handler can re-issue + * the corresponding input request rather than hard-fail. + */ + droppedInputResponseKeys?: string[]; + + /** + * 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. + * + * SECURITY: `requestState` round-trips through the client and MUST be + * treated as attacker-controlled input. The SDK applies no integrity + * protection: if this value influences authorization, resource + * access, or business logic, the server MUST integrity-protect it + * (e.g. HMAC or AEAD) when minting it and MUST verify it here, + * rejecting state that fails verification (spec: + * basic/patterns/mrtr, server requirements 4–5). + */ + requestState?: string; + /** * An abort signal used to communicate if the request was cancelled from the sender's side. */ @@ -220,6 +421,11 @@ export type ServerContext = BaseContext & { /** * Send an elicitation request to the client, requesting user input. + * + * @deprecated Throws on a 2026-07-28-era request — return `inputRequired(...)` + * (multi-round-trip) from the handler instead. The 2025 push-style server-to-client request model is + * replaced by input_required results in the 2026-07-28 protocol. If your factory serves + * both eras, this only works on the legacy path. */ elicitInput: (params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions) => Promise; @@ -227,8 +433,10 @@ export type ServerContext = BaseContext & { * Request LLM sampling from the client. * * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). - * Remains functional during the deprecation window (at least twelve months). - * Migrate to calling LLM provider APIs directly. + * Throws on a 2026-07-28-era request — return `inputRequired(...)` (multi-round-trip) + * from the handler instead, or migrate to calling LLM provider APIs directly. The 2025 push-style + * server-to-client request model is replaced by input_required results in the 2026-07-28 + * protocol. If your factory serves both eras, this only works on the legacy path. */ requestSampling: ( params: CreateMessageRequest['params'], @@ -273,6 +481,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 +519,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 +589,99 @@ export abstract class Protocol { */ protected abstract buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ContextT; + /** + * Drop consult for inbound messages whose transport did not classify them + * at the edge — long-lived channels such as stdio, where a role class may + * need to decline traffic the negotiated era has no answer for (the + * client-side inbound-request drop on modern-era connections: the + * 2026-07-28 era has no server→client request channel, and on stdio the + * client must never write JSON-RPC responses). + * + * Consulted ONLY when the transport supplied no + * {@linkcode MessageExtraInfo.classification}: edge-classified traffic + * never reaches the hook. Returning `'drop'` discards the message without + * writing any response (requests are surfaced via `onerror`). The base + * implementation returns `undefined`: unclassified traffic keeps today's + * dispatch path unchanged. Era selection never happens here — era is + * instance state, owned by the serving entry that constructed and + * connected the instance. + */ + protected _shouldDropInbound(_message: JSONRPCRequest | JSONRPCNotification): 'drop' | undefined { + return undefined; + } + + /** + * The per-request `_meta` envelope this instance attaches to every outgoing + * request and notification, when one applies. The base implementation + * returns `undefined` (no envelope — the 2025-era posture, so legacy-era + * outbound traffic is byte-identical to a build without this seam). + * `Client` overrides it on a connection that negotiated a modern (2026-07-28+) + * era to return the reserved protocol-version / client-info / + * client-capabilities keys. User-supplied `_meta` keys take precedence over + * the auto-attached ones. + */ + protected _outboundMetaEnvelope(): Readonly> | undefined { + return undefined; + } + + /** + * Attach this instance's outbound `_meta` envelope (when one is configured) + * to a request or notification. A no-op when the seam returns `undefined` + * — the message returns by reference, so the legacy-era wire stays + * byte-identical. User-supplied `_meta` keys are spread last so they win + * over the auto-attached envelope keys. + */ + private _envelopeOutbound(message: T): T { + const envelope = this._outboundMetaEnvelope(); + if (envelope === undefined) { + return message; + } + const params = (message.params ?? {}) as { _meta?: Record }; + return { + ...message, + params: { ...params, _meta: { ...envelope, ...params._meta } } + }; + } + + /** + * Extension point for non-`complete` decoded results in the response + * funnel: a result the wire codec discriminated into a kind other than + * `'complete'` or `'invalid'` is handed here for the role class to + * resolve. The base default surfaces it as a typed + * {@linkcode SdkErrorCode.UnsupportedResultType} error (no retry). + * + * Intended consumers (named so the seam stays accountable): + * - the `Client`'s multi-round-trip auto-fulfilment engine, which fulfils + * `'input_required'` results through the registered + * elicitation/sampling/roots handlers and retries via `flow.retry`; + * - a future client-side terminal-result handler for + * `subscriptions/listen`, when the spec defines one. + * + * `Server` instances never receive `input_required` responses on their + * outbound legs and leave the base behavior in place. + */ + protected _resolveNonCompleteResult( + decoded: ReturnType & { kind: 'input_required' }, + flow: NonCompleteResultFlow + ): Promise { + return Promise.reject( + new SdkError(SdkErrorCode.UnsupportedResultType, `Unsupported result type '${decoded.kind}' for ${flow.request.method}`, { + resultType: decoded.kind, + method: flow.request.method + }) + ); + } + + /** + * Protected accessor for a registered request handler. Used by role + * classes that dispatch synthesized requests through the same stored + * handler chain (e.g. the `Client` fulfilling an embedded multi-round-trip + * input request). + */ + protected _getRequestHandler(method: string): ((request: JSONRPCRequest, ctx: ContextT) => Promise) | undefined { + return this._requestHandlers.get(method); + } + private async _oncancel(notification: CancelledNotification): Promise { if (!notification.params.requestId) { return; @@ -423,7 +764,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)}`)); } @@ -435,7 +776,12 @@ export abstract class Protocol { await this._transport.start(); } - private _onclose(): void { + /** + * Transport-close hook. Subclass overrides MUST call `super._onclose()` + * after their own cleanup — base teardown (response-handler settlement, + * timeout clearing, in-flight request abort) does not run otherwise. + */ + protected _onclose(): void { const responseHandlers = this._responseHandlers; this._responseHandlers = new Map(); this._progressHandlers.clear(); @@ -470,69 +816,232 @@ export abstract class Protocol { this.onerror?.(error); } - private _onnotification(notification: JSONRPCNotification): void { - const handler = this._notificationHandlers.get(notification.method) ?? this.fallbackNotificationHandler; + /** + * Inbound-notification dispatch. Subclass overrides MUST delegate + * unmatched traffic to `super._onnotification(rawNotification, extra)` — + * an override that consumes only what it owns and falls through to base + * dispatch for everything else. + */ + protected _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). Classification is never a per-message era switch — an + // edge classification is validated against the instance era below. + const codec = this._negotiatedWireCodec(); + + // Drop consult (only when the transport did not classify; edge- + // classified traffic never reaches the hook): a role class may decline + // unclassified inbound traffic the negotiated era has no answer for. + if (extra?.classification === undefined && this._shouldDropInbound(rawNotification) === 'drop') { + return; + } + + // 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). Classification (Q2; produced at the transport/entry + // edge — this layer only CONSUMES MessageExtraInfo.classification) 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. + const codec = this._negotiatedWireCodec(); + + // Drop consult (only when the transport did not classify; edge- + // classified traffic never reaches the hook): a role class may decline + // unclassified inbound traffic the negotiated era has no answer for. + if (extra?.classification === undefined && this._shouldDropInbound(rawRequest) === 'drop') { + this._onerror(new Error(`Dropped inbound request '${rawRequest.method}': not servable on this connection's protocol era`)); + return; + } // 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 (−32022 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); + // Multi-round-trip retry material: only BARE response objects are + // surfaced to the handler; entries that look like a wrapped + // `{method, result}` shape (or are not objects at all) are dropped + // and their keys recorded so the handler can re-issue the input + // request instead of hard-failing (D-059 posture). + const partitionedInputResponses = lifted.inputResponses === undefined ? undefined : partitionInputResponses(lifted.inputResponses); + const baseCtx: BaseContext = { sessionId: capturedTransport?.sessionId, mcpReq: { id: request.id, method: request.method, _meta: request.params?._meta, + ...(lifted.envelope !== undefined && { envelope: lifted.envelope }), + ...(partitionedInputResponses !== undefined && { inputResponses: partitionedInputResponses.accepted }), + ...(partitionedInputResponses !== undefined && + partitionedInputResponses.droppedKeys.length > 0 && { + droppedInputResponseKeys: partitionedInputResponses.droppedKeys + }), + ...(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); - if (!resultSchema) { + const validate = codecResultValidator(sendCodec, r.method); + if (validate === undefined) { throw new TypeError( `'${r.method}' is not a spec method; pass a result schema as the second argument to ctx.mcpReq.send().` ); } - return sendRequest(r, resultSchema, schemaOrOptions); + return sendRequest(r, validate, schemaOrOptions); }) as BaseContext['mcpReq']['send'], notify: sendNotification }, @@ -550,8 +1059,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 }; @@ -563,11 +1089,16 @@ export abstract class Protocol { return; } + // The error half of the encode seam: the era codec selects + // the wire code for a handler-thrown error, so per-era + // wire-code policy lives in the codec rather than in any + // handler. Non-integer codes still fall through to −32603. + const thrownCode = Number.isSafeInteger(error['code']) ? (error['code'] as number) : ProtocolErrorCode.InternalError; const errorResponse: JSONRPCErrorResponse = { jsonrpc: '2.0', id: request.id, error: { - code: Number.isSafeInteger(error['code']) ? error['code'] : ProtocolErrorCode.InternalError, + code: codec.encodeErrorCode(thrownCode), message: error.message ?? 'Internal error', ...(error['data'] !== undefined && { data: error['data'] }) } @@ -612,7 +1143,13 @@ export abstract class Protocol { handler(params); } - private _onresponse(response: JSONRPCResponse | JSONRPCErrorResponse): void { + /** + * Inbound-response dispatch. Subclass overrides MUST delegate unmatched + * traffic to `super._onresponse(response)` — an override that consumes + * only what it owns and falls through to base dispatch for everything + * else. + */ + protected _onresponse(response: JSONRPCResponse | JSONRPCErrorResponse): void { const messageId = Number(response.id); const handler = this._responseHandlers.get(messageId); @@ -685,28 +1222,108 @@ 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); - if (!resultSchema) { + const validate = codecResultValidator(codec, request.method); + if (validate === undefined) { 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, validate, 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); + } + + /** + * Protected accessor for the instance's negotiated wire codec, for role + * classes (Client/Server/McpServer) routing era-dependent behavior + * through the codec's function-only surface — `samplingResultVariant`, + * `outboundEnvelope`, `projectCallToolResult` — instead of branching on + * the protocol version themselves. + */ + protected _wireCodec(): WireCodec { + return this._negotiatedWireCodec(); + } + + /** + * 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(); } /** - * Sends a request and waits for a response, using the provided schema for validation. + * 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 { relatedRequestId, resumptionToken, onresumptiontoken } = options ?? {}; + 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, headers } = options ?? {}; + // Flow start for non-complete result resolution: `maxTotalTimeout` + // bounds the WHOLE flow, so the budget is measured from the original + // request, not from when an extension takes over after the first leg. + const flowStartedAt = Date.now(); let onAbort: (() => void) | undefined; let cleanupMessageId: number | undefined; @@ -731,7 +1348,29 @@ export abstract class Protocol { } } - options?.signal?.throwIfAborted(); + // An already-aborted caller signal must surface the same way an + // in-flight abort does (`SdkError(RequestTimeout, reason)` via + // `cancel()` below). Bare `throwIfAborted()` would propagate the + // raw `signal.reason` instead, so callers that introduce an async + // hop before `request()` (e.g. a cache freshness check) would see + // a different rejection type depending on where the abort lands. + if (options?.signal?.aborted) { + const reason = options.signal.reason; + throw reason instanceof SdkError ? reason : new SdkError(SdkErrorCode.RequestTimeout, String(reason)); + } + + // Spec basic/patterns/cancellation §Transport-Specific (2026-07-28): + // on Streamable HTTP, closing the per-request SSE stream IS the + // cancellation signal — "no notifications/cancelled message is + // required or expected". When the negotiated era is modern AND the + // transport opens a per-request stream (`hasPerRequestStream`), + // cancel() aborts that stream via `requestSignal` INSTEAD OF + // POSTing `notifications/cancelled`. Every other (era × transport) + // combination — legacy era on any transport, modern era on stdio / + // in-memory — keeps today's `notifications/cancelled` POST path + // unchanged. + const streamCloseCancels = codec.era === MODERN_WIRE_REVISION && this._transport.hasPerRequestStream === true; + const requestAbort = streamCloseCancels ? new AbortController() : undefined; const messageId = this._requestMessageId++; cleanupMessageId = messageId; @@ -752,6 +1391,12 @@ export abstract class Protocol { }; } + // Per-request envelope auto-attach (after the progressToken merge so + // both share the same `_meta`): a no-op on the legacy era — the + // envelope seam returns undefined and the request goes out exactly as + // built above. + const outbound = this._envelopeOutbound(jsonrpcRequest); + let responseReceived = false; const cancel = (reason: unknown) => { @@ -760,19 +1405,28 @@ export abstract class Protocol { } this._progressHandlers.delete(messageId); - this._transport - ?.send( - { - jsonrpc: '2.0', - method: 'notifications/cancelled', - params: { - requestId: messageId, - reason: String(reason) - } - }, - { relatedRequestId, resumptionToken, onresumptiontoken } - ) - .catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`))); + if (requestAbort === undefined) { + this._transport + ?.send( + this._envelopeOutbound({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { + requestId: messageId, + reason: String(reason) + } + }), + { relatedRequestId, resumptionToken, onresumptiontoken } + ) + .catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`))); + } else { + // Modern-era per-request-stream transport: aborting the + // request's underlying stream IS the spec cancel signal. + // The transport already swallows the resulting AbortError + // (no spurious `onerror`); a post-abort send() rejection + // re-hits an already-settled promise below and is a no-op. + requestAbort.abort(); + } // Wrap the reason in an SdkError if it isn't already const error = reason instanceof SdkError ? reason : new SdkError(SdkErrorCode.RequestTimeout, String(reason)); @@ -789,7 +1443,58 @@ 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') { + // Manual mode (the primitive any driver layers over): + // hand the input-required value back to the caller. + if (options?.allowInputRequired === true) { + return resolve(manualInputRequiredValue(decoded) as StandardSchemaV1.InferOutput); + } + // Non-complete result extension point: the role class may + // resolve the flow itself (the Client wires the + // multi-round-trip auto-fulfilment engine here). The base + // default is the typed UnsupportedResultType error. + const flow: NonCompleteResultFlow = { + codec, + request, + resultSchema, + options, + flowStartedAt, + retry: (params, legOptions) => + this._requestWithSchemaViaCodec( + codec, + params === undefined ? { method: request.method } : { method: request.method, params }, + resultSchema, + legOptions + ) + }; + return resolve(this._resolveNonCompleteResult(decoded, flow) as Promise>); + } + const result = decoded.result; + + validateStandardSchema(resultSchema, result).then(parseResult => { if (parseResult.success) { resolve(parseResult.data); } else { @@ -806,10 +1511,12 @@ export abstract class Protocol { this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); - this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { - this._progressHandlers.delete(messageId); - reject(error); - }); + this._transport + .send(outbound, { relatedRequestId, resumptionToken, onresumptiontoken, headers, requestSignal: requestAbort?.signal }) + .catch(error => { + this._progressHandlers.delete(messageId); + reject(error); + }); }).finally(() => { // Per-request cleanup that must run on every exit path. Consolidated // here so new exit paths added to the promise body can't forget it. @@ -829,13 +1536,32 @@ 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 }; + const jsonrpcNotification = this._envelopeOutbound({ jsonrpc: '2.0' as const, ...notification }); const debouncedMethods = this._options?.debouncedNotificationMethods ?? []; // A notification can only be debounced if it's in the list AND it's "simple" @@ -897,7 +1623,7 @@ export abstract class Protocol { */ setRequestHandler( method: M, - handler: (request: RequestTypeMap[M], ctx: ContextT) => ResultTypeMap[M] | Promise + handler: (request: RequestTypeMap[M], ctx: ContextT) => HandlerResultTypeMap[M] | Promise ): void; setRequestHandler

( method: string, @@ -914,18 +1640,47 @@ 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. On the 2026-07-28 era the demoted server→client methods + // (elicitation/sampling/roots) are not wire request methods — + // they reach a handler only as embedded input requests dispatched + // by the multi-round-trip driver, and parse with the era's + // in-band schema instead. + stored = (request, ctx) => { + const dispatchCodec = this._negotiatedWireCodec(); + let outcome = dispatchCodec.validateRequest(method, request); + if (!outcome.ok && outcome.reason === 'not-in-era') { + outcome = dispatchCodec.validateInputRequest(method, request); + } + if (!outcome.ok) { + if (outcome.reason === 'not-in-era') { + // 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`); + } + // Preserves the pre-function-only error surface: the Zod + // parse threw out of the handler and the funnel mapped it + // to −32603 Internal error (specCorpusDispatch pins this). + throw new Error(outcome.message); + } + return Promise.resolve(schemasOrHandler(outcome.value, 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 +1733,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 +1752,28 @@ 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 outcome = codec.validateNotification(method, notification); + if (!outcome.ok) { + if (outcome.reason === 'not-in-era') { + // 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`); + } + // Preserves the pre-function-only error surface (parse + // threw out of the handler). + throw new Error(outcome.message); + } + return Promise.resolve(schemasOrHandler(outcome.value)); + }); return; } @@ -1009,9 +1781,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..bfe85242e2 --- /dev/null +++ b/packages/core/src/shared/protocolEras.ts @@ -0,0 +1,48 @@ +/** + * 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 protocol era of a connection: `'legacy'` for the 2025-11-25 family and + * earlier (negotiated via `initialize`), `'modern'` for 2026-07-28 and later + * (negotiated via `server/discover`; every request carries a `_meta` envelope). + */ +export type ProtocolEra = 'legacy' | 'modern'; + +/** + * 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/shared/transport.ts b/packages/core/src/shared/transport.ts index c606e2e3b5..ce64b67fdd 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -67,6 +67,39 @@ export type TransportSendOptions = { * This allows clients to persist the latest token for potential reconnection. */ onresumptiontoken?: ((token: string) => void) | undefined; + + /** + * An abort signal for THIS outbound message's underlying request, when the + * transport sends one outbound message per underlying request (the + * Streamable HTTP transport's POST-per-request model). Aborting it cancels + * the underlying request (and its SSE response stream) without closing the + * transport. Transports that share a single channel (stdio, in-memory) + * ignore it. + */ + requestSignal?: AbortSignal | undefined; + + /** + * Fired by transports that open a per-request stream (the Streamable HTTP + * transport's POST-per-request SSE response) when that stream ends or + * errors for any reason OTHER than a deliberate `requestSignal` abort — + * i.e. the server closed the stream, the network dropped it, or + * reconnection was exhausted. Transports that share a single channel + * (stdio, in-memory) ignore it. + */ + onRequestStreamEnd?: (() => void) | undefined; + + /** + * Additional HTTP headers to send with THIS outbound message, when the + * transport sends one outbound message per underlying HTTP request (the + * Streamable HTTP transport's POST-per-request model). Transports that + * share a single channel (stdio, in-memory) ignore it. + * + * The Client uses this to attach SEP-2243 `Mcp-Param-{Name}` headers to a + * `tools/call` request on a 2026-07-28 connection. Values are sent + * verbatim — encode anything that is not a safe RFC 9110 field value + * before passing it here. + */ + headers?: Readonly> | undefined; }; /** * Describes the minimal contract for an MCP transport that a client or server can communicate over. @@ -93,6 +126,18 @@ export interface Transport { */ close(): Promise; + /** + * `true` when this transport opens one underlying request per outbound + * JSON-RPC request (the Streamable HTTP POST-per-request model) and + * therefore honors {@linkcode TransportSendOptions.requestSignal}. The + * 2026-07-28 spec makes closing that per-request stream the cancellation + * signal — the protocol layer aborts `requestSignal` instead of POSTing + * `notifications/cancelled` when this flag is set on a 2026-era + * connection. Transports that share a single channel (stdio, in-memory) + * leave it `undefined`. + */ + readonly hasPerRequestStream?: boolean; + /** * Callback for when the connection is closed for any reason. * 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..61e6ca7f1f 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) */ @@ -26,6 +31,19 @@ export const CLIENT_INFO_META_KEY = 'io.modelcontextprotocol/clientInfo'; */ export const CLIENT_CAPABILITIES_META_KEY = 'io.modelcontextprotocol/clientCapabilities'; +/** + * `_meta` key carrying the JSON-RPC ID of the `subscriptions/listen` request + * that opened the stream a notification was delivered on. + * + * Stamped by the server on every notification delivered via a + * `subscriptions/listen` stream (including the leading + * `notifications/subscriptions/acknowledged`); on stdio, where all messages + * share one channel, clients use it to correlate notifications with their + * originating subscription. The value is the listen request's JSON-RPC ID + * verbatim. + */ +export const SUBSCRIPTION_ID_META_KEY = 'io.modelcontextprotocol/subscriptionId'; + /** * `_meta` key carrying the desired log level for a request. * diff --git a/packages/core/src/types/enums.ts b/packages/core/src/types/enums.ts index 0e3b65f9f0..be4649e8f0 100644 --- a/packages/core/src/types/enums.ts +++ b/packages/core/src/types/enums.ts @@ -11,16 +11,27 @@ export enum ProtocolErrorCode { InternalError = -32_603, // MCP-specific error codes + /** + * Resource not found. + * + * Receive-tolerated only: the SDK never EMITS `-32002` — `resources/read` + * misses answer `-32602` (Invalid Params) on every protocol revision per + * the 2026-07-28 spec MUST, and a handler-thrown `-32002` is mapped to + * `-32602` at the era encode seam. The member stays importable so clients + * can recognise `-32002` from peers built on earlier SDK releases (the + * spec's "clients SHOULD also accept `-32002`" backwards-compatibility + * clause). Throw `ResourceNotFoundError` instead. + */ ResourceNotFound = -32_002, /** * Processing the request requires a capability the client did not declare * in the request's `clientCapabilities` (protocol revision 2026-07-28). */ - MissingRequiredClientCapability = -32_003, + MissingRequiredClientCapability = -32_021, /** * The request's protocol version is unknown to the server or unsupported * by it (protocol revision 2026-07-28). */ - UnsupportedProtocolVersion = -32_004, + UnsupportedProtocolVersion = -32_022, UrlElicitationRequired = -32_042 } diff --git a/packages/core/src/types/errors.ts b/packages/core/src/types/errors.ts index a175686d13..4cf8fc9036 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,11 +39,68 @@ export class ProtocolError extends Error { } } + // Resource not found is recognised on BOTH the spec-mandated −32602 and + // the legacy −32002 (the spec's "clients SHOULD also accept −32002" + // backwards-compatibility clause). On −32602 the data-shape parse is + // narrowed to "exactly `{ uri: string }` and nothing else": a server's + // own Invalid Params that happens to carry `data.uri` alongside other + // keys (e.g. `{ uri, reason: 'uri must be https' }`) stays a generic + // ProtocolError. On −32002 the code itself is the discriminator, so any + // `data.uri` string suffices. + if (code === ProtocolErrorCode.InvalidParams || code === ProtocolErrorCode.ResourceNotFound) { + const errorData = data as Record | undefined; + if ( + typeof errorData?.uri === 'string' && + (code === ProtocolErrorCode.ResourceNotFound || Object.keys(errorData).length === 1) + ) { + return new ResourceNotFoundError(errorData.uri, message); + } + } + + 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); } } +/** + * Error type for a `resources/read` miss: the requested resource does not + * exist. The wire code is `-32602` (Invalid Params) on every protocol + * revision — the spec MUST for revision 2026-07-28, and the value the v1.x + * SDK has always emitted on earlier revisions. The error data echoes the + * requested URI. + * + * Recognise this error by checking `error.data` is exactly `{ uri: string }` + * (a `-32602` whose data carries `uri` and nothing else is resource-not-found; + * any other `-32602` is an ordinary Invalid Params). For backwards compatibility, clients should also + * accept `-32002` as resource not found — earlier SDK builds emitted that + * code, and {@linkcode ProtocolError.fromError} reconstructs this class for + * either code **when `error.data` carries `uri`** (a bare `-32002` without + * `data.uri` stays a generic {@linkcode ProtocolError}). Do not rely on + * `instanceof` — it does not work across separately bundled copies of the + * SDK. + */ +export class ResourceNotFoundError extends ProtocolError { + constructor(uri: string, message: string = `Resource not found: ${uri}`) { + super(ProtocolErrorCode.InvalidParams, message, { uri }); + } + + /** The URI that was requested and not found. */ + get uri(): string { + return (this.data as { uri: string }).uri; + } +} + /** * Specialized error type when a tool requires a URL mode elicitation. * This makes it nicer for the client to handle since there is specific data to work with instead of just a code to check against. @@ -56,7 +118,7 @@ export class UrlElicitationRequiredError extends ProtocolError { } /** - * Error type for the `-32004` UnsupportedProtocolVersion protocol error (protocol + * Error type for the `-32022` UnsupportedProtocolVersion protocol error (protocol * revision 2026-07-28): the request's protocol version is unknown to the server or * unsupported by it. * @@ -83,3 +145,34 @@ export class UnsupportedProtocolVersionError extends ProtocolError { return (this.data as UnsupportedProtocolVersionErrorData).requested; } } + +/** + * Error type for the `-32021` 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..0a5f4b7cd4 100644 --- a/packages/core/src/types/guards.ts +++ b/packages/core/src/types/guards.ts @@ -17,6 +17,7 @@ import type { CompleteRequestResourceTemplate, InitializedNotification, InitializeRequest, + InputRequiredResult, JSONRPCErrorResponse, JSONRPCMessage, JSONRPCNotification, @@ -72,6 +73,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. @@ -81,11 +88,32 @@ export const isCallToolResult = (value: unknown): value is CallToolResult => { return CallToolResultSchema.safeParse(value).success; }; +/** + * Checks whether a value is an input-required result (protocol revision + * 2026-07-28): the multi-round-trip return shape discriminated by + * `resultType: 'input_required'`. + * + * This is a discriminator check, not a full validator — the at-least-one rule + * (`inputRequests` or `requestState`) is enforced by the `inputRequired()` + * builder and re-checked by the server seam for hand-built values. + * + * @param value - The value to check. + * @returns True if the value carries the `input_required` discriminator. + */ +export const isInputRequiredResult = (value: unknown): value is InputRequiredResult => + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + (value as { resultType?: unknown }).resultType === 'input_required'; + /** * Checks if a value is a valid {@linkcode TaskAugmentedRequestParams}. * @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..9ea3048416 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, SUBSCRIPTION_ID_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. @@ -1031,6 +898,87 @@ export const UnsubscribeRequestSchema = RequestSchema.extend({ params: UnsubscribeRequestParamsSchema }); +/* Subscriptions (protocol revision 2026-07-28) */ +/** + * The set of notification types a client opts in to on a `subscriptions/listen` + * request. Each type is opt-in; the server MUST NOT send a notification type + * the client has not explicitly requested here. + */ +export const SubscriptionFilterSchema = z.object({ + /** + * If true, receive `notifications/tools/list_changed`. + */ + toolsListChanged: z.boolean().optional(), + /** + * If true, receive `notifications/prompts/list_changed`. + */ + promptsListChanged: z.boolean().optional(), + /** + * If true, receive `notifications/resources/list_changed`. + */ + resourcesListChanged: z.boolean().optional(), + /** + * Subscribe to `notifications/resources/updated` for these resource URIs. + * Replaces the former `resources/subscribe` RPC on the 2026-07-28 revision. + */ + resourceSubscriptions: z.array(z.string()).optional() +}); + +export const SubscriptionsListenRequestParamsSchema = BaseRequestParamsSchema.extend({ + /** + * The notifications the client opts in to on this stream. The server MUST + * NOT send notification types the client has not explicitly requested. + */ + notifications: SubscriptionFilterSchema +}); + +/** + * Sent from the client to open a long-lived channel for receiving notifications + * outside the context of a specific request (protocol revision 2026-07-28). + * Replaces the previous HTTP GET endpoint and `resources/subscribe`. + */ +export const SubscriptionsListenRequestSchema = RequestSchema.extend({ + method: z.literal('subscriptions/listen'), + params: SubscriptionsListenRequestParamsSchema +}); + +export const SubscriptionsAcknowledgedNotificationParamsSchema = NotificationsParamsSchema.extend({ + /** + * The subset of requested notification types the server agreed to honor. + */ + notifications: SubscriptionFilterSchema +}); + +/** + * Sent by the server as the first message on a `subscriptions/listen` stream + * to acknowledge that the subscription has been established and report which + * notification types it agreed to honor (protocol revision 2026-07-28). + */ +export const SubscriptionsAcknowledgedNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/subscriptions/acknowledged'), + params: SubscriptionsAcknowledgedNotificationParamsSchema +}); + +/** + * `_meta` for a {@linkcode SubscriptionsListenResult}: the listen request's + * JSON-RPC ID under the canonical subscription-id key (mirroring the same key + * on every notification delivered on the stream). + */ +export const SubscriptionsListenResultMetaSchema = z.looseObject({ + [SUBSCRIPTION_ID_META_KEY]: RequestIdSchema +}); + +/** + * The response to a `subscriptions/listen` request, signalling that the + * subscription has ended gracefully (for example, during server shutdown). + * Because the listen stream is long-lived, this result is sent only when the + * server tears the subscription down; an abrupt transport close carries no + * response. The result body is otherwise empty. + */ +export const SubscriptionsListenResultSchema = ResultSchema.extend({ + _meta: SubscriptionsListenResultMetaSchema +}); + /** * Parameters for a {@linkcode ResourceUpdatedNotification | notifications/resources/updated} notification. */ @@ -1201,6 +1149,10 @@ export const AudioContentSchema = z.object({ /** * A tool call request from an assistant (LLM). * Represents the assistant's request to use a tool. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. */ export const ToolUseContentSchema = z.object({ type: z.literal('tool_use'), @@ -1382,18 +1334,14 @@ export const ToolSchema = z.object({ }) .catchall(z.unknown()), /** - * An optional JSON Schema 2020-12 object defining the structure of the tool's output + * An optional JSON Schema 2020-12 document describing the structure of the tool's output * returned in the `structuredContent` field of a `CallToolResult`. - * Must have `type: 'object'` at the root level per MCP spec. + * + * SEP-2106: any JSON Schema root is permitted (e.g. `type:'array'`, `oneOf`, `$ref`). + * The 2025-11-25 wire parse retains the `type:'object'` constraint via the frozen schema in + * `wire/rev2025-11-25/schemas.ts`; this neutral/public schema widens. */ - outputSchema: z - .object({ - type: z.literal('object'), - properties: z.record(z.string(), JSONValueSchema).optional(), - required: z.array(z.string()).optional() - }) - .catchall(z.unknown()) - .optional(), + outputSchema: z.looseObject({ $schema: z.string().optional() }).optional(), /** * Optional additional tool information. */ @@ -1432,16 +1380,21 @@ 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. + * Structured tool output. + * + * If the `Tool` defines an `outputSchema`, this field MUST be present in the result and + * contain a JSON value that matches the schema. * - * If the `Tool` defines an outputSchema, this field MUST be present in the result, and contain a JSON object that matches the schema. + * SEP-2106: any JSON value is permitted (arrays, primitives, `null`). Narrow before property + * access. The 2025-11-25 wire parse retains the object-only constraint via the frozen schema + * in `wire/rev2025-11-25/schemas.ts`; this neutral/public schema widens. */ - structuredContent: z.record(z.string(), z.unknown()).optional(), + structuredContent: z.unknown().optional(), /** * Whether the tool call ended in an error. @@ -1527,11 +1480,19 @@ export const ListChangedOptionsBaseSchema = z.object({ /* Logging */ /** * The severity of a log message. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to stderr logging + * (STDIO servers) or OpenTelemetry. */ export const LoggingLevelSchema = z.enum(['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency']); /** * Parameters for a `logging/setLevel` request. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to stderr logging + * (STDIO servers) or OpenTelemetry. */ export const SetLevelRequestParamsSchema = BaseRequestParamsSchema.extend({ /** @@ -1541,6 +1502,10 @@ export const SetLevelRequestParamsSchema = BaseRequestParamsSchema.extend({ }); /** * A request from the client to the server, to enable or adjust logging. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to stderr logging + * (STDIO servers) or OpenTelemetry. */ export const SetLevelRequestSchema = RequestSchema.extend({ method: z.literal('logging/setLevel'), @@ -1549,6 +1514,10 @@ export const SetLevelRequestSchema = RequestSchema.extend({ /** * Parameters for a `notifications/message` notification. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to stderr logging + * (STDIO servers) or OpenTelemetry. */ export const LoggingMessageNotificationParamsSchema = NotificationsParamsSchema.extend({ /** @@ -1566,57 +1535,23 @@ export const LoggingMessageNotificationParamsSchema = NotificationsParamsSchema. }); /** * Notification 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. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to stderr logging + * (STDIO servers) or OpenTelemetry. */ export const LoggingMessageNotificationSchema = NotificationSchema.extend({ method: z.literal('notifications/message'), 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. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. */ export const ModelHintSchema = z.object({ /** @@ -1627,6 +1562,10 @@ export const ModelHintSchema = z.object({ /** * The server's preferences for model selection, requested of the client during sampling. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. */ export const ModelPreferencesSchema = z.object({ /** @@ -1649,6 +1588,10 @@ export const ModelPreferencesSchema = z.object({ /** * Controls tool usage behavior in sampling requests. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. */ export const ToolChoiceSchema = z.object({ /** @@ -1663,12 +1606,20 @@ export const ToolChoiceSchema = z.object({ /** * The result of a tool execution, provided by the user (server). * Represents the outcome of invoking a tool requested via `ToolUseContent`. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. */ 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([]), - structuredContent: z.object({}).loose().optional(), + content: z.array(ContentBlockSchema), + /** + * SEP-2106: any JSON value is permitted. The 2025-11-25 wire parse retains the object-only + * constraint via the frozen schema in `wire/rev2025-11-25/schemas.ts`. + */ + structuredContent: z.unknown().optional(), isError: z.boolean().optional(), /** @@ -1681,12 +1632,20 @@ export const ToolResultContentSchema = z.object({ /** * Basic content types for sampling responses (without tool use). * Used for backwards-compatible {@linkcode CreateMessageResult} when tools are not used. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. */ export const SamplingContentSchema = z.discriminatedUnion('type', [TextContentSchema, ImageContentSchema, AudioContentSchema]); /** * Content block types allowed in sampling messages. * This includes text, image, audio, tool use requests, and tool results. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. */ export const SamplingMessageContentBlockSchema = z.discriminatedUnion('type', [ TextContentSchema, @@ -1698,6 +1657,10 @@ export const SamplingMessageContentBlockSchema = z.discriminatedUnion('type', [ /** * Describes a message issued to or received from an LLM API. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. */ export const SamplingMessageSchema = z.object({ role: RoleSchema, @@ -1711,6 +1674,10 @@ export const SamplingMessageSchema = z.object({ /** * Parameters for a `sampling/createMessage` request. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. */ export const CreateMessageRequestParamsSchema = TaskAugmentedRequestParamsSchema.extend({ messages: z.array(SamplingMessageSchema), @@ -1726,8 +1693,12 @@ export const CreateMessageRequestParamsSchema = TaskAugmentedRequestParamsSchema * A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. * The client MAY ignore this request. * - * Default is `"none"`. Values `"thisServer"` and `"allServers"` are soft-deprecated. Servers SHOULD only use these values if the client - * declares `ClientCapabilities`.`sampling.context`. These values may be removed in future spec releases. + * Default is `"none"`. The values `"thisServer"` and `"allServers"` are deprecated (SEP-2596): servers SHOULD + * omit this field or use `"none"`, and SHOULD only use the deprecated values if the client declares + * `ClientCapabilities`.`sampling.context`. + * + * @deprecated The `"thisServer"` and `"allServers"` values are deprecated as of protocol version 2025-11-25 + * (SEP-2596) and will be removed no later than the Sampling feature itself (SEP-2577). Omit this field or use `"none"`. */ includeContext: z.enum(['none', 'thisServer', 'allServers']).optional(), temperature: z.number().optional(), @@ -1756,6 +1727,10 @@ export const CreateMessageRequestParamsSchema = TaskAugmentedRequestParamsSchema }); /** * 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. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. */ export const CreateMessageRequestSchema = RequestSchema.extend({ method: z.literal('sampling/createMessage'), @@ -1766,6 +1741,10 @@ export const CreateMessageRequestSchema = RequestSchema.extend({ * The client's response to a `sampling/create_message` request from the server. * This is the backwards-compatible version that returns single content (no arrays). * Used when the request does not include tools. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. */ export const CreateMessageResultSchema = ResultSchema.extend({ /** @@ -1793,6 +1772,10 @@ export const CreateMessageResultSchema = ResultSchema.extend({ /** * The client's response to a `sampling/create_message` request when tools were provided. * This version supports array content for tool use flows. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. */ export const CreateMessageResultWithToolsSchema = ResultSchema.extend({ /** @@ -1990,6 +1973,11 @@ export const ElicitRequestURLParamsSchema = TaskAugmentedRequestParamsSchema.ext /** * The ID of the elicitation, which must be unique within the context of the server. * The client MUST treat this ID as an opaque value. + * + * @deprecated Removed from the spec by #2891 (2026-07-28). The client learns the + * outcome of an out-of-band interaction by retrying the original request; no + * server-initiated completion signal exists in the 2026-07-28 revision. Kept here + * for the 2025-era URL-mode flow only. */ elicitationId: z.string(), /** @@ -2016,11 +2004,17 @@ export const ElicitRequestSchema = RequestSchema.extend({ /** * Parameters for a {@linkcode ElicitationCompleteNotification | notifications/elicitation/complete} notification. * + * @deprecated Removed from the spec by #2891 (2026-07-28). The client learns the outcome + * of an out-of-band interaction by retrying the original request; no server-initiated + * completion signal exists in the 2026-07-28 revision. Kept here for the 2025-era flow + * only. The 2026-07-28 wire codec excludes this notification. * @category notifications/elicitation/complete */ export const ElicitationCompleteNotificationParamsSchema = NotificationsParamsSchema.extend({ /** * The ID of the elicitation that completed. + * + * @deprecated See {@linkcode ElicitationCompleteNotificationParamsSchema}. */ elicitationId: z.string() }); @@ -2028,6 +2022,10 @@ export const ElicitationCompleteNotificationParamsSchema = NotificationsParamsSc /** * A notification from the server to the client, informing it of a completion of an out-of-band elicitation request. * + * @deprecated Removed from the spec by #2891 (2026-07-28). The client learns the outcome + * of an out-of-band interaction by retrying the original request; no server-initiated + * completion signal exists in the 2026-07-28 revision. Kept here for the 2025-era flow + * only. The 2026-07-28 wire codec excludes this notification. * @category notifications/elicitation/complete */ export const ElicitationCompleteNotificationSchema = NotificationSchema.extend({ @@ -2139,6 +2137,10 @@ export const CompleteResultSchema = ResultSchema.extend({ /* Roots */ /** * Represents a root directory or file that the server can operate on. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to passing paths via + * tool parameters, resource URIs, or configuration. */ export const RootSchema = z.object({ /** @@ -2159,6 +2161,10 @@ export const RootSchema = z.object({ /** * Sent from the server to request a list of root URIs from the client. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to passing paths via + * tool parameters, resource URIs, or configuration. */ export const ListRootsRequestSchema = RequestSchema.extend({ method: z.literal('roots/list'), @@ -2167,6 +2173,10 @@ export const ListRootsRequestSchema = RequestSchema.extend({ /** * The client's response to a `roots/list` request from the server. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to passing paths via + * tool parameters, resource URIs, or configuration. */ export const ListRootsResultSchema = ResultSchema.extend({ roots: z.array(RootSchema) @@ -2174,16 +2184,196 @@ export const ListRootsResultSchema = ResultSchema.extend({ /** * A notification from the client to the server, informing it that the list of roots has changed. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to passing paths via + * tool parameters, resource URIs, or configuration. */ export const RootsListChangedNotificationSchema = NotificationSchema.extend({ method: z.literal('notifications/roots/list_changed'), params: NotificationsParamsSchema.optional() }); +/* ─────────────────────────────────────────────────────────────────────────── + * Tasks (2025-11-25 wire vocabulary, DEPRECATED) + * + * The task message surface defined by the 2025-11-25 protocol revision. These + * schemas are kept in the neutral layer so the public Task* types stay + * nameable without a cross-layer import into wire/rev*; the wire-parse + * contract for them is the FROZEN copy in wire/rev2025-11-25/schemas.ts. + * + * They appear in NO role aggregate below and no API signature — nameable-only + * vocabulary for interop with task-capable 2025 peers (#2248). Removable at + * the major version that drops 2025-era support. + * ─────────────────────────────────────────────────────────────────────────── */ + +/** + * 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']); + +/** + * 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); + /* 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; the deprecated Task* +// schemas above are nameable-only and appear in NO role aggregate and no API +// signature. The era's full wire role unions live in +// `wire/rev2025-11-25/schemas.ts`. export const ClientRequestSchema = z.union([ PingRequestSchema, InitializeRequestSchema, + DiscoverRequestSchema, CompleteRequestSchema, SetLevelRequestSchema, GetPromptRequestSchema, @@ -2193,20 +2383,16 @@ export const ClientRequestSchema = z.union([ ReadResourceRequestSchema, SubscribeRequestSchema, UnsubscribeRequestSchema, + SubscriptionsListenRequestSchema, 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 +2400,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 +2414,14 @@ export const ServerNotificationSchema = z.union([ ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, - TaskStatusNotificationSchema, + SubscriptionsAcknowledgedNotificationSchema, ElicitationCompleteNotificationSchema ]); export const ServerResultSchema = z.union([ EmptyResultSchema, InitializeResultSchema, + DiscoverResultSchema, CompleteResultSchema, GetPromptResultSchema, ListPromptsResultSchema, @@ -2255,92 +2430,5 @@ export const ServerResultSchema = z.union([ ReadResourceResultSchema, CallToolResultSchema, ListToolsResultSchema, - GetTaskResultSchema, - ListTasksResultSchema, - CreateTaskResultSchema + SubscriptionsListenResultSchema ]); - -/* 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..a8ec4f8077 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: f68d864a813754e188c6df52dcc5772a12f96c63 * * 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 @@ -110,6 +110,29 @@ export interface RequestMetaObject extends MetaObject { 'io.modelcontextprotocol/logLevel'?: LoggingLevel; } +/** + * Extends {@link MetaObject} with additional notification-specific fields. All key naming rules from `MetaObject` apply. + * + * @see {@link MetaObject} for key naming rules and reserved prefixes. + * @see [General fields: `_meta`](/specification/draft/basic/index#meta) for more details. + * @category Common Types + */ +export interface NotificationMetaObject extends MetaObject { + /** + * Identifies the subscription stream a notification was delivered on. The + * server MUST include this key on every notification delivered via a + * {@link SubscriptionsListenRequest | subscriptions/listen} stream, so the + * client can correlate the notification with the originating subscription. + * The key is absent on notifications not delivered via a subscription + * stream (e.g. progress notifications for an in-flight request), which is + * why it is optional here. + * + * The value is the JSON-RPC ID of the `subscriptions/listen` request that + * opened the stream. + */ + 'io.modelcontextprotocol/subscriptionId'?: RequestId; +} + /** * A progress token, used to associate progress notifications with the original request. * @@ -147,7 +170,7 @@ export interface Request { * @category Common Types */ export interface NotificationParams { - _meta?: MetaObject; + _meta?: NotificationMetaObject; } /** @internal */ @@ -298,7 +321,7 @@ export interface InvalidRequestError extends Error { * * In 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). * - * A request that requires a client capability the client did not declare is signalled instead by {@link MissingRequiredClientCapabilityError} (`-32003`). + * A request that requires a client capability the client did not declare is signalled instead by {@link MissingRequiredClientCapabilityError} (`-32021`). * * @see {@link https://www.jsonrpc.org/specification#error_object | JSON-RPC 2.0 Error Object} * @@ -357,13 +380,42 @@ export interface InternalError extends Error { code: typeof INTERNAL_ERROR; } +/* + * MCP error codes. + * + * JSON-RPC 2.0 reserves `-32000` to `-32099` for implementation-defined + * server errors. MCP partitions that range: + * + * - `-32000` to `-32019`: implementation-defined. Existing SDKs and + * implementations use codes here for their own purposes; the specification + * will never define codes in this sub-range, and receivers must not assign + * cross-implementation semantics to them. + * - `-32020` to `-32099`: reserved for error codes defined by the MCP + * specification. Every code allocated here is recorded in this file. + * Codes are allocated sequentially starting at `-32020` and proceeding + * toward `-32099`. + * + * Codes defined by earlier protocol versions remain reserved and are never + * reused: `-32002` (resource not found, 2025-11-25 and earlier; replaced by + * `-32602`) and `-32042` (URL elicitation required, 2025-11-25 only). + */ + +/** + * Error code returned when the HTTP headers of a request do not match the + * corresponding values in the request body, or required headers are + * missing or malformed. + * + * @category Errors + */ +export const HEADER_MISMATCH = -32020; + /** * Error code returned when a server requires a client capability that was * not declared in the request's `clientCapabilities`. * * @category Errors */ -export const MISSING_REQUIRED_CLIENT_CAPABILITY = -32003; +export const MISSING_REQUIRED_CLIENT_CAPABILITY = -32021; /** * Error code returned when the request's protocol version is not supported @@ -371,7 +423,24 @@ export const MISSING_REQUIRED_CLIENT_CAPABILITY = -32003; * * @category Errors */ -export const UNSUPPORTED_PROTOCOL_VERSION = -32004; +export const UNSUPPORTED_PROTOCOL_VERSION = -32022; + +/** + * Returned when a server rejects a request because the values in the HTTP + * headers do not match the corresponding values in the request body, or + * because required headers are missing or malformed. For HTTP, the response + * status code MUST be `400 Bad Request`. + * + * @example Header mismatch + * {@includeCode ./examples/HeaderMismatchError/header-mismatch.json} + * + * @category Errors + */ +export interface HeaderMismatchError extends Omit { + error: Error & { + code: typeof HEADER_MISMATCH; + }; +} /** * Returned when the request's protocol version is unknown to the server or @@ -517,9 +586,9 @@ export interface CancelledNotificationParams extends NotificationParams { /** * The ID of the request to cancel. * - * This MUST correspond to the ID of a request previously issued in the same direction. + * This MUST correspond to the ID of a request the client previously issued. */ - requestId?: RequestId; + requestId: RequestId; /** * An optional string describing the reason for the cancellation. This MAY be logged or presented to the user. @@ -528,7 +597,9 @@ export interface CancelledNotificationParams extends NotificationParams { } /** - * This notification can be sent by either side to indicate that it is cancelling a previously-issued request. + * This notification is sent by the client to indicate that it is cancelling a request it previously issued. + * + * On stdio, the server also sends this notification, solely to terminate a {@link SubscriptionsListenRequest | subscriptions/listen} stream: it references the ID of the `subscriptions/listen` request that opened the stream. Servers MUST NOT use this notification to cancel any other request. * * The 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. * @@ -569,7 +640,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 +745,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 +842,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} */ @@ -989,11 +1066,13 @@ export interface CacheableResult extends Result { * Indicates the intended scope of the cached response, analogous to HTTP * `Cache-Control: public` vs `Cache-Control: private`. * - * - `"public"`: Any client or intermediary (e.g., shared gateway, proxy) - * MAY cache the response and serve it to any user. - * - `"private"`: Only the requesting user's client MAY cache the response. - * Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached - * copy to a different user. + * - `"public"`: The response does not contain user-specific data. Any + * client or intermediary (e.g., shared gateway, caching proxy) MAY cache + * the response and serve it across authorization contexts. + * - `"private"`: The response MAY be cached and reused only within the + * same authorization context. Caches MUST NOT be shared across + * authorization contexts (e.g., a different access token requires a + * different cache). * */ cacheScope: 'public' | 'private'; @@ -1134,7 +1213,7 @@ export interface ReadResourceResultResponse extends JSONRPCResultResponse { } /** - * 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. + * An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This is only delivered on a {@link SubscriptionsListenRequest | subscriptions/listen} stream when the client requested it via the `resourcesListChanged` filter field. * * @example Resources list changed * {@includeCode ./examples/ResourceListChangedNotification/resources-list-changed.json} @@ -1204,6 +1283,40 @@ export interface SubscriptionsListenRequest extends JSONRPCRequest { params: SubscriptionsListenRequestParams; } +/** + * Extends {@link MetaObject} with the subscription-stream identifier carried by a + * {@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply. + * + * @see {@link MetaObject} for key naming rules and reserved prefixes. + * @category `subscriptions/listen` + */ +export interface SubscriptionsListenResultMeta extends MetaObject { + /** + * Identifies the subscription stream this response closes, so the client can + * correlate it with the originating subscription — mirroring the same key on + * the stream's notifications. The value is the JSON-RPC ID of the + * `subscriptions/listen` request that opened the stream (and equals this + * response's `id`). + */ + 'io.modelcontextprotocol/subscriptionId': RequestId; +} + +/** + * The response to a {@link SubscriptionsListenRequest | subscriptions/listen} + * request, signalling that the subscription has ended gracefully (for example, + * during server shutdown). Because the listen stream is long-lived, this result + * is sent only when the server tears the subscription down; an abrupt transport + * close carries no response. The result body is otherwise empty. + * + * @example Subscription closed gracefully + * {@includeCode ./examples/SubscriptionsListenResult/listen-closed.json} + * + * @category `subscriptions/listen` + */ +export interface SubscriptionsListenResult extends Result { + _meta: SubscriptionsListenResultMeta; +} + /** * Parameters for a {@link SubscriptionsAcknowledgedNotification | notifications/subscriptions/acknowledged} notification. * @@ -1578,7 +1691,7 @@ export interface EmbeddedResource { _meta?: MetaObject; } /** - * 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. + * An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This is only delivered on a {@link SubscriptionsListenRequest | subscriptions/listen} stream when the client requested it via the `promptsListChanged` filter field. * * @example Prompts list changed * {@includeCode ./examples/PromptListChangedNotification/prompts-list-changed.json} @@ -1720,7 +1833,7 @@ export interface CallToolRequest extends JSONRPCRequest { } /** - * 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. + * An optional notification from the server to the client, informing it that the list of tools it offers has changed. This is only delivered on a {@link SubscriptionsListenRequest | subscriptions/listen} stream when the client requested it via the `toolsListChanged` filter field. * * @example Tools list changed * {@includeCode ./examples/ToolListChangedNotification/tools-list-changed.json} @@ -1822,6 +1935,11 @@ export interface Tool extends BaseMetadata, Icons { * (`if`/`then`/`else`), reference keywords (`$ref`, `$defs`, `$anchor`), and any other * standard validation or annotation keywords. * + * Property schemas may carry an `x-mcp-header` annotation to mirror the + * argument value into an HTTP header on the Streamable HTTP transport. See + * the Streamable HTTP transport specification for the validity and + * extraction rules. + * * Defaults to JSON Schema 2020-12 when no explicit `$schema` is provided. */ inputSchema: { $schema?: string; type: 'object'; [key: string]: unknown }; @@ -2533,7 +2651,9 @@ export interface PromptReference extends BaseMetadata { */ export interface ListRootsRequest { method: 'roots/list'; - params?: RequestParams; + params?: { + _meta?: MetaObject; + }; } /** @@ -2643,12 +2763,6 @@ export interface ElicitRequestURLParams { */ message: string; - /** - * The ID of the elicitation, which must be unique within the context of the server. - * The client MUST treat this ID as an opaque value. - */ - elicitationId: string; - /** * The URL that the user should navigate to. * @@ -2963,24 +3077,6 @@ export interface ElicitResult { content?: { [key: string]: string | number | boolean | string[] }; } -/** - * An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request. - * - * @example Elicitation complete - * {@includeCode ./examples/ElicitationCompleteNotification/elicitation-complete.json} - * - * @category `notifications/elicitation/complete` - */ -export interface ElicitationCompleteNotification extends JSONRPCNotification { - method: 'notifications/elicitation/complete'; - params: { - /** - * The ID of the elicitation that completed. - */ - elicitationId: string; - }; -} - /* Client messages */ /** @internal */ export type ClientRequest = @@ -2996,7 +3092,7 @@ export type ClientRequest = | ListToolsRequest; /** @internal */ -export type ClientNotification = CancelledNotification | ProgressNotification; +export type ClientNotification = CancelledNotification; /** @internal */ export type ClientResult = EmptyResult; @@ -3012,7 +3108,6 @@ export type ServerNotification = | ResourceListChangedNotification | ToolListChangedNotification | PromptListChangedNotification - | ElicitationCompleteNotification | SubscriptionsAcknowledgedNotification; /** @internal */ @@ -3025,6 +3120,7 @@ export type ServerResult = | ListResourceTemplatesResult | ListResourcesResult | ReadResourceResult + | SubscriptionsListenResult | CallToolResult | ListToolsResult | InputRequiredResult; diff --git a/packages/core/src/types/specTypeSchema.ts b/packages/core/src/types/specTypeSchema.ts index e538da8fa5..c3cc3e0e74 100644 --- a/packages/core/src/types/specTypeSchema.ts +++ b/packages/core/src/types/specTypeSchema.ts @@ -135,7 +135,6 @@ const SPEC_SCHEMA_KEYS = [ 'RelatedTaskMetadataSchema', 'RequestSchema', 'RequestIdSchema', - 'RequestMetaEnvelopeSchema', 'RequestMetaSchema', 'ResourceSchema', 'ResourceContentsSchema', @@ -163,10 +162,17 @@ const SPEC_SCHEMA_KEYS = [ 'StringSchemaSchema', 'SubscribeRequestSchema', 'SubscribeRequestParamsSchema', - 'TaskSchema', + 'SubscriptionFilterSchema', + 'SubscriptionsAcknowledgedNotificationSchema', + 'SubscriptionsAcknowledgedNotificationParamsSchema', + 'SubscriptionsListenRequestSchema', + 'SubscriptionsListenRequestParamsSchema', + 'SubscriptionsListenResultSchema', + 'SubscriptionsListenResultMetaSchema', 'TaskAugmentedRequestParamsSchema', 'TaskCreationParamsSchema', 'TaskMetadataSchema', + 'TaskSchema', 'TaskStatusSchema', 'TaskStatusNotificationSchema', 'TaskStatusNotificationParamsSchema', @@ -223,7 +229,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..d61b43bd04 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -4,7 +4,17 @@ import type * as z from 'zod/v4'; -import type { INTERNAL_ERROR, INVALID_PARAMS, INVALID_REQUEST, METHOD_NOT_FOUND, PARSE_ERROR } from './constants.js'; +import type { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + INTERNAL_ERROR, + INVALID_PARAMS, + INVALID_REQUEST, + LOG_LEVEL_META_KEY, + METHOD_NOT_FOUND, + PARSE_ERROR, + PROTOCOL_VERSION_META_KEY +} from './constants.js'; import type { AnnotationsSchema, AudioContentSchema, @@ -62,10 +72,8 @@ import type { InitializeRequestSchema, InitializeResultSchema, JSONRPCErrorResponseSchema, - JSONRPCMessageSchema, JSONRPCNotificationSchema, JSONRPCRequestSchema, - JSONRPCResponseSchema, JSONRPCResultResponseSchema, LegacyTitledEnumSchemaSchema, ListPromptsRequestSchema, @@ -108,7 +116,6 @@ import type { ReadResourceResultSchema, RelatedTaskMetadataSchema, RequestIdSchema, - RequestMetaEnvelopeSchema, RequestMetaSchema, RequestSchema, ResourceContentsSchema, @@ -137,6 +144,13 @@ import type { StringSchemaSchema, SubscribeRequestParamsSchema, SubscribeRequestSchema, + SubscriptionFilterSchema, + SubscriptionsAcknowledgedNotificationParamsSchema, + SubscriptionsAcknowledgedNotificationSchema, + SubscriptionsListenRequestParamsSchema, + SubscriptionsListenRequestSchema, + SubscriptionsListenResultMetaSchema, + SubscriptionsListenResultSchema, TaskAugmentedRequestParamsSchema, TaskCreationParamsSchema, TaskMetadataSchema, @@ -186,31 +200,61 @@ 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; /** * The per-request `_meta` envelope carried by every request under protocol revision * 2026-07-28 (protocol version, client info, client capabilities, optional log level). + * + * Neutral hand-written shape keyed by the public meta-key constants — never + * inferred from a wire-module schema (this neutral layer does not import from + * `wire/rev*`). A `type` alias rather than an interface so it stays assignable + * to `_meta`'s string-indexed object slot. */ -export type RequestMetaEnvelope = Infer; +export type RequestMetaEnvelope = { + [PROTOCOL_VERSION_META_KEY]: string; + [CLIENT_INFO_META_KEY]: Implementation; + [CLIENT_CAPABILITIES_META_KEY]: ClientCapabilities; + [LOG_LEVEL_META_KEY]?: LoggingLevel; +}; /* Empty result */ -export type EmptyResult = Infer; +export type EmptyResult = StripWireOnly>; /* Cancellation */ export type CancelledNotificationParams = Infer; @@ -243,12 +287,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 +302,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 +357,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; @@ -304,23 +372,42 @@ export type UnsubscribeRequest = Infer; export type ResourceUpdatedNotificationParams = Infer; export type ResourceUpdatedNotification = Infer; +/* Subscriptions (protocol revision 2026-07-28) */ +export type SubscriptionFilter = Infer; +export type SubscriptionsListenRequestParams = Infer; +export type SubscriptionsListenRequest = Infer; +export type SubscriptionsAcknowledgedNotificationParams = Infer; +export type SubscriptionsAcknowledgedNotification = Infer; +export type SubscriptionsListenResultMeta = Infer; +export type SubscriptionsListenResult = StripWireOnly>; + /* Prompts */ export type PromptArgument = 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; export type ImageContent = Infer; export type AudioContent = Infer; +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ export type ToolUseContent = Infer; +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ export type ToolResultContent = Infer; 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,31 +415,106 @@ 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; /* Logging */ +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to stderr logging + * (STDIO servers) or OpenTelemetry. + */ export type LoggingLevel = Infer; +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to stderr logging + * (STDIO servers) or OpenTelemetry. + */ export type SetLevelRequestParams = Infer; +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to stderr logging + * (STDIO servers) or OpenTelemetry. + */ export type SetLevelRequest = Infer; +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to stderr logging + * (STDIO servers) or OpenTelemetry. + */ export type LoggingMessageNotificationParams = Infer; +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to stderr logging + * (STDIO servers) or OpenTelemetry. + */ export type LoggingMessageNotification = Infer; /* Sampling */ +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ export type ToolChoice = Infer; +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ export type ModelHint = Infer; +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ export type ModelPreferences = Infer; +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ export type SamplingContent = Infer; +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ export type SamplingMessageContentBlock = Infer; +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ export type SamplingMessage = Infer; +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ export type CreateMessageRequestParams = Infer; +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ export type CreateMessageRequest = Infer; -export type CreateMessageResult = Infer; -export type CreateMessageResultWithTools = Infer; +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ +export type CreateMessageResult = StripWireOnly>; +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ +export type CreateMessageResultWithTools = StripWireOnly>; /* Elicitation */ export type BooleanSchema = Infer; @@ -371,44 +533,151 @@ export type ElicitRequestParams = Infer; export type ElicitRequestFormParams = Infer; export type ElicitRequestURLParams = Infer; export type ElicitRequest = Infer; +/** @deprecated Removed from the spec by #2891 (2026-07-28). 2025-era only; the 2026-07-28 wire codec excludes this notification. */ export type ElicitationCompleteNotificationParams = Infer; +/** @deprecated Removed from the spec by #2891 (2026-07-28). 2025-era only; the 2026-07-28 wire codec excludes this notification. */ 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 */ +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to passing paths via + * tool parameters, resource URIs, or configuration. + */ export type Root = Infer; +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to passing paths via + * tool parameters, resource URIs, or configuration. + */ export type ListRootsRequest = Infer; -export type ListRootsResult = Infer; +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to passing paths via + * tool parameters, resource URIs, or configuration. + */ +export type ListRootsResult = StripWireOnly>; +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to passing paths via + * tool parameters, resource URIs, or configuration. + */ export type RootsListChangedNotification = Infer; +/* Multi round-trip requests (protocol revision 2026-07-28) + * + * On the 2026-07-28 revision the server obtains client input (elicitation, + * sampling, roots) in-band: instead of sending a server→client JSON-RPC + * request, a handler for one of the multi-round-trip methods (`tools/call`, + * `prompts/get`, `resources/read`) returns an input-required result carrying + * de-JSON-RPC'd embedded requests; the client fulfils them and retries the + * original request with the responses. These are the NEUTRAL shapes of that + * surface — handlers author them and the 2026-07-28 wire codec alone maps + * them to/from the wire. + */ + +/** + * A single embedded (de-JSON-RPC'd) input request inside an + * {@linkcode InputRequiredResult}: an elicitation, sampling, or roots request + * object carried in-band rather than sent as a server→client JSON-RPC request. + */ +export type InputRequest = CreateMessageRequest | ListRootsRequest | ElicitRequest; + +/** + * A single embedded (de-JSON-RPC'd) input response inside a retried request's + * `inputResponses`: the bare result object for the corresponding + * {@linkcode InputRequest} (never wrapped in a `{method, result}` envelope). + */ +export type InputResponse = CreateMessageResult | ListRootsResult | ElicitResult; + +/** + * A map of embedded input requests, keyed by server-assigned identifiers that + * are unique within the scope of the request. + */ +export interface InputRequests { + [key: string]: InputRequest; +} + +/** + * A map of embedded input responses. Keys correspond to the keys of the + * {@linkcode InputRequests} map the server sent; values are the client's bare + * result for each request. + */ +export interface InputResponses { + [key: string]: InputResponse; +} + +/** + * The input-required result a handler for a multi-round-trip method + * (`tools/call`, `prompts/get`, `resources/read`) returns to request more + * input from the client (protocol revision 2026-07-28). Build it with the + * `inputRequired()` builder; hand-built literals are equally legal — + * `resultType: 'input_required'` is the discriminator, and the SDK re-checks + * the at-least-one rule at the seam. + * + * This is the one place the wire discriminator `resultType` appears on the + * neutral surface: the handler authors it, the 2026-07-28 codec passes it + * through to the wire, and consumers receiving results never see it (complete + * results are lifted). + * + * At least one of `inputRequests` or `requestState` must be present. + * + * `requestState` is an opaque, server-minted string echoed back verbatim by + * the client on retry. It travels through the client and MUST be treated by + * the server as attacker-controlled input on re-entry: if it influences + * authorization, resource access, or business logic, the server MUST protect + * its integrity (e.g. HMAC or AEAD) and MUST reject state that fails + * verification (spec: basic/patterns/mrtr §Server Requirements). The SDK + * surfaces it raw at `ctx.mcpReq.requestState` and applies no integrity + * protection of its own. + */ +export interface InputRequiredResult extends Result { + resultType: 'input_required'; + /** Embedded requests the client must fulfil before retrying. */ + inputRequests?: InputRequests; + /** Opaque server state the client echoes back verbatim on retry. */ + requestState?: string; +} + /* 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 +687,33 @@ export type ResultTypeMap = { 'resources/read': ReadResourceResult; 'resources/subscribe': EmptyResult; 'resources/unsubscribe': EmptyResult; - 'tools/call': CallToolResult | CreateTaskResult; + // `subscriptions/listen` receives a JSON-RPC result only on a server-side + // graceful close (the empty `SubscriptionsListenResult`). Listen requests + // never reach `request()` / the typed result map — `Client.listen()` sends + // directly on the transport and demuxes the response in `_onresponse`. + 'subscriptions/listen': SubscriptionsListenResult; + '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; +}; + +/** + * The handler-return counterpart of {@linkcode ResultTypeMap}: what a + * registered request handler may RETURN for each method. Identical to + * `ResultTypeMap` except that the multi-round-trip methods (`tools/call`, + * `prompts/get`, `resources/read`) additionally accept an + * {@linkcode InputRequiredResult} (protocol revision 2026-07-28). + * + * `ResultTypeMap` itself — what a *requester* receives — is deliberately NOT + * widened: `client.callTool()` returns a plain {@linkcode CallToolResult} on + * both protocol eras. + */ +export type HandlerResultTypeMap = { + [M in keyof ResultTypeMap]: M extends 'tools/call' | 'prompts/get' | 'resources/read' + ? ResultTypeMap[M] | InputRequiredResult + : ResultTypeMap[M]; }; /** @@ -485,7 +772,20 @@ export interface InternalError extends JSONRPCErrorObject { } /** - * Data carried by a `-32004` UnsupportedProtocolVersion protocol error + * Data carried by a `-32021` 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 `-32022` UnsupportedProtocolVersion protocol error * (protocol revision 2026-07-28). */ export interface UnsupportedProtocolVersionErrorData { @@ -555,6 +855,30 @@ 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. + */ +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 +888,14 @@ 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); it does not select the era itself. + */ + classification?: MessageClassification; + /** * The authentication information. */ @@ -588,11 +920,19 @@ export type RequestMetaObject = RequestMeta; /** * {@linkcode CreateMessageRequestParams} without tools - for backwards-compatible overload. * Excludes tools/toolChoice to indicate they should not be provided. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. */ export type CreateMessageRequestParamsBase = Omit; /** * {@linkcode CreateMessageRequestParams} with required tools - for tool-enabled overload. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. */ export interface CreateMessageRequestParamsWithTools extends CreateMessageRequestParams { tools: Tool[]; diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts index b938885de0..f5e1283f26 100644 --- a/packages/core/src/util/standardSchema.ts +++ b/packages/core/src/util/standardSchema.ts @@ -167,12 +167,13 @@ let warnedZodFallback = false; /** * Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema. * - * MCP requires `type: "object"` at the root of tool inputSchema/outputSchema and - * prompt argument schemas. Zod's discriminated unions emit `{oneOf: [...]}` without - * a top-level `type`, so this function defaults `type` to `"object"` when absent. - * - * Throws if the schema has an explicit non-object `type` (e.g. `z.string()`), - * since that cannot satisfy the MCP spec. + * MCP requires `type: "object"` at the root of tool `inputSchema` and prompt + * argument schemas; `outputSchema` may have any JSON Schema root (SEP-2106). + * Zod's discriminated unions emit `{oneOf: [...]}` without a top-level `type`, + * so for `io: 'input'` this function defaults `type` to `"object"` when absent + * and throws on an explicit non-object `type` (e.g. `z.string()`). For + * `io: 'output'` a non-object root is returned as-is; the `"object"` default is + * applied only when the root is provably object-shaped. */ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'input' | 'output' = 'input'): Record { const std = schema['~standard']; @@ -204,6 +205,21 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in `Upgrade to a version that does, or wrap your JSON Schema with fromJsonSchema().` ); } + if (io === 'output') { + // SEP-2106: outputSchema may have any JSON Schema root. An explicit `type` (object or + // not) is returned as-is. A typeless root only gets `type:'object'` defaulted when it is + // PROVABLY object-shaped — either it carries object keywords at the root, or every + // member of a root `oneOf`/`anyOf`/`allOf` is itself `type:'object'` (the + // `z.discriminatedUnion(...)`, `z.union([z.object(...), ...])`, `z.intersection(...)` + // cases). Those pre-SEP schemas were valid 2025 wire data via the unconditional stamp, + // so the stamp is kept where it is provably safe. A typeless root that is NOT provably + // object-shaped (e.g. `z.union([z.string(), z.number()])` → `{anyOf:[…]}`) is returned + // as-is — stamping there would be self-contradictory. Anything that does not end up + // `type:'object'` is wrapped as `{type:'object', properties:{result:…}}` by the 2025 + // codec's legacy projection (see `wire/rev2025-11-25/legacyWrap.ts`). + if (result.type !== undefined) return result; + return isProvablyObjectShapedRoot(result) ? { type: 'object', ...result } : result; + } if (result.type !== undefined && result.type !== 'object') { throw new Error( `MCP tool and prompt schemas must describe objects (got type: ${JSON.stringify(result.type)}). ` + @@ -213,6 +229,31 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in return { type: 'object', ...result }; } +/** + * A typeless JSON Schema root is "provably object-shaped" when either it carries object keywords + * directly (`properties`/`patternProperties`/`additionalProperties`/`required`), or it is a + * composition (`oneOf`/`anyOf`/`allOf`) whose every member is itself `type:'object'` or recursively + * provably object-shaped (e.g. a nested `discriminatedUnion`). `$ref` is not followed. Used to + * decide whether stamping `type:'object'` is safe (redundant-but-valid) versus self-contradictory. + */ +function isProvablyObjectShapedRoot(schema: Record): boolean { + if ('properties' in schema || 'patternProperties' in schema || 'additionalProperties' in schema || 'required' in schema) { + return true; + } + for (const key of ['oneOf', 'anyOf', 'allOf'] as const) { + const members = schema[key]; + if (Array.isArray(members) && members.length > 0) { + return members.every( + m => + m !== null && + typeof m === 'object' && + ((m as Record).type === 'object' || isProvablyObjectShapedRoot(m as Record)) + ); + } + } + return false; +} + // Validation export type StandardSchemaValidationResult = { success: true; data: T } | { success: false; error: string }; diff --git a/packages/core/src/validators/ajvProvider.examples.ts b/packages/core/src/validators/ajvProvider.examples.ts index abf8d1572a..7258c20291 100644 --- a/packages/core/src/validators/ajvProvider.examples.ts +++ b/packages/core/src/validators/ajvProvider.examples.ts @@ -7,7 +7,9 @@ * @module */ -import { addFormats, Ajv, AjvJsonSchemaValidator } from './ajvProvider.js'; +import { Ajv2020 } from 'ajv/dist/2020.js'; + +import { addFormats, AjvJsonSchemaValidator } from './ajvProvider.js'; /** * Example: Default AJV instance. @@ -21,10 +23,17 @@ function AjvJsonSchemaValidator_default() { /** * Example: Custom AJV instance. + * + * The SDK bundles ajv internally but does not re-export `Ajv2020` (its type graph tips downstream + * declaration bundling — see #2339). To construct a custom 2020-12 instance, add `ajv` to your own + * dependencies (matching the SDK's pinned version) and `import { Ajv2020 } from 'ajv/dist/2020.js'` + * so the custom instance keeps validating JSON Schema 2020-12 (SEP-1613). Passing `new Ajv(...)` + * (the draft-07 class) would silently downgrade dialect. */ function AjvJsonSchemaValidator_customInstance() { //#region AjvJsonSchemaValidator_customInstance - const ajv = new Ajv({ strict: true, allErrors: true }); + // import { Ajv2020 } from 'ajv/dist/2020.js'; + const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true }); const validator = new AjvJsonSchemaValidator(ajv); //#endregion AjvJsonSchemaValidator_customInstance return validator; @@ -33,12 +42,15 @@ function AjvJsonSchemaValidator_customInstance() { /** * Example: Custom AJV instance with formats registered. * - * `Ajv` and `addFormats` are re-exported from this module so customising the validator - * requires no extra `package.json` dependencies — both come from the SDK's bundled copy. + * `addFormats` is re-exported from this module. The SDK bundles ajv internally but does not + * re-export `Ajv2020` (its type graph tips downstream declaration bundling — see #2339). To + * construct a custom 2020-12 instance, add `ajv` to your own dependencies (matching the SDK's + * pinned version) and `import { Ajv2020 } from 'ajv/dist/2020.js'`. */ function AjvJsonSchemaValidator_withFormats() { //#region AjvJsonSchemaValidator_withFormats - const ajv = new Ajv({ strict: true, allErrors: true }); + // import { Ajv2020 } from 'ajv/dist/2020.js'; + const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true }); addFormats(ajv); const validator = new AjvJsonSchemaValidator(ajv); //#endregion AjvJsonSchemaValidator_withFormats diff --git a/packages/core/src/validators/ajvProvider.ts b/packages/core/src/validators/ajvProvider.ts index f62a8469ae..59c7c87952 100644 --- a/packages/core/src/validators/ajvProvider.ts +++ b/packages/core/src/validators/ajvProvider.ts @@ -2,11 +2,21 @@ * AJV-based JSON Schema validator provider */ -import { Ajv } from 'ajv'; +import { Ajv2020 } from 'ajv/dist/2020.js'; import _addFormats from 'ajv-formats'; import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types.js'; +/** + * Canonical 2020-12 `$schema` URIs (http + https variants, trailing-`#` stripped). When a schema + * declares anything else, the default provider throws a plain `Error` with a clear message rather + * than letting the engine crash on an opaque internal error or silently mis-validate. + */ +const DRAFT_2020_12_URIS: ReadonlySet = new Set([ + 'https://json-schema.org/draft/2020-12/schema', + 'http://json-schema.org/draft/2020-12/schema' +]); + /** Structural subset of the AJV interface used by {@link AjvJsonSchemaValidator}. */ interface AjvLike { compile: (schema: unknown) => AjvValidateFunction; @@ -21,17 +31,16 @@ interface AjvValidateFunction { errors?: any; } -function createDefaultAjvInstance(): Ajv { - const ajv = new Ajv({ +const addFormats = _addFormats as unknown as typeof _addFormats.default; + +function createDefaultAjvInstance(): AjvLike { + const ajv = new Ajv2020({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }); - - const addFormats = _addFormats as unknown as typeof _addFormats.default; addFormats(ajv); - return ajv; } @@ -39,6 +48,14 @@ function createDefaultAjvInstance(): Ajv { * AJV-backed JSON Schema validator. See `@modelcontextprotocol/{client,server}/validators/ajv` * for the customisation entry point (re-exports `Ajv` and `addFormats` from the bundled copy). * + * Default validates as **JSON Schema 2020-12** (SEP-1613). Schemas declaring a different + * `$schema` are rejected with a plain `Error`; pass a pre-configured Ajv instance to validate + * other dialects. The SDK bundles ajv internally but does not re-export `Ajv2020` (its type + * graph tips downstream declaration bundling — see #2339). To construct a custom 2020-12 + * instance, add `ajv` to your own dependencies (matching the SDK's pinned version) and + * `import { Ajv2020 } from 'ajv/dist/2020.js'` — `new Ajv(...)` is the draft-07 class and would + * silently downgrade dialect. + * * @example Use with default configuration * ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_default" * const validator = new AjvJsonSchemaValidator(); @@ -46,31 +63,54 @@ function createDefaultAjvInstance(): Ajv { * * @example Use with a custom AJV instance * ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_customInstance" - * const ajv = new Ajv({ strict: true, allErrors: true }); + * // import { Ajv2020 } from 'ajv/dist/2020.js'; + * const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true }); * const validator = new AjvJsonSchemaValidator(ajv); * ``` * * @example Register ajv-formats * ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_withFormats" - * const ajv = new Ajv({ strict: true, allErrors: true }); + * // import { Ajv2020 } from 'ajv/dist/2020.js'; + * const ajv = new Ajv2020({ strict: false, validateSchema: false, allErrors: true }); * addFormats(ajv); * const validator = new AjvJsonSchemaValidator(ajv); * ``` */ export class AjvJsonSchemaValidator implements jsonSchemaValidator { - private _ajv: AjvLike; + private readonly _ajv: AjvLike; + /** True iff the constructor received a caller-supplied engine; the `$schema` check is skipped. */ + private readonly _userAjv: boolean; /** - * @param ajv - Optional pre-configured AJV-compatible instance. If omitted, a default instance is - * created with `strict: false`, `validateFormats: true`, `validateSchema: false`, `allErrors: true`, - * and `ajv-formats` registered. The parameter is typed structurally so consumers who don't pass - * an instance need not have `ajv` installed. + * @param ajv - Optional pre-configured AJV-compatible instance. When supplied, this instance is + * used for **every** schema regardless of its declared `$schema` (the caller owns dialect + * choice). When omitted, the provider constructs a single `Ajv2020` instance with + * `strict: false`, `validateFormats: true`, `validateSchema: false`, `allErrors: true`, and + * `ajv-formats` registered. The parameter is typed structurally so consumers who don't pass an + * instance need not have `ajv` installed. */ constructor(ajv?: AjvLike) { + this._userAjv = ajv !== undefined; this._ajv = ajv ?? createDefaultAjvInstance(); } getValidator(schema: JsonSchemaType): JsonSchemaValidator { + // Caller supplied a specific engine — do not second-guess by `$schema` + // (bring-your-own-validator means bring-your-own-dialect). + if ( + !this._userAjv && + '$schema' in schema && + typeof schema.$schema === 'string' && + !DRAFT_2020_12_URIS.has(schema.$schema.replace(/#$/, '')) + ) { + const declared = schema.$schema.slice(0, 200); + throw new Error( + `JSON Schema declares an unsupported dialect ("$schema": "${declared}"). ` + + `The default validator supports JSON Schema 2020-12 only; pass a pre-configured ` + + `Ajv instance to AjvJsonSchemaValidator(ajv) to validate other dialects.` + ); + } + const ajvValidator = '$id' in schema && typeof schema.$id === 'string' ? (this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema)) @@ -94,6 +134,24 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator { } } +/** + * Draft-07 AJV class, re-exported for consumers who need to opt back to the pre-SEP-1613 default. + * The full v1-equivalent construction is: + * + * ```ts + * const ajv = new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }); + * addFormats(ajv); + * new AjvJsonSchemaValidator(ajv); + * ``` + * + * (omitting `validateSchema: false` makes a 2020-12-stamped `$schema` fail with an opaque + * "no schema with key or ref …" engine error; omitting `addFormats` silently drops `format` + * validation that the v1 default had). + * + * The SDK bundles ajv internally but does not re-export `Ajv2020` (its type graph tips downstream + * declaration bundling — see #2339). To construct a custom 2020-12 instance, add `ajv` to your own + * dependencies (matching the SDK's pinned version) and `import { Ajv2020 } from 'ajv/dist/2020.js'`. + */ export { Ajv } from 'ajv'; /** `ajv-formats` default export, normalised through the CJS/ESM interop wrapper. */ -export const addFormats = _addFormats as unknown as typeof _addFormats.default; +export { addFormats }; diff --git a/packages/core/src/validators/cfWorkerProvider.ts b/packages/core/src/validators/cfWorkerProvider.ts index 6fcc3d507e..dde545fe59 100644 --- a/packages/core/src/validators/cfWorkerProvider.ts +++ b/packages/core/src/validators/cfWorkerProvider.ts @@ -17,11 +17,24 @@ import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSche */ export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; +/** + * Canonical 2020-12 `$schema` URIs (http + https variants, trailing-`#` stripped). When a schema + * declares anything else and no `{draft}` is forced, the provider throws a plain `Error`. + */ +const DRAFT_2020_12_URIS: ReadonlySet = new Set([ + 'https://json-schema.org/draft/2020-12/schema', + 'http://json-schema.org/draft/2020-12/schema' +]); + /** * `@cfworker/json-schema`-backed JSON Schema validator. See * `@modelcontextprotocol/{client,server}/validators/cf-worker` for the customisation entry point. * - * @example Use with default configuration (draft 2020-12, shortcircuit on) + * Default validates as **JSON Schema 2020-12** (SEP-1613). Schemas declaring a different + * `$schema` are rejected with a plain `Error`. Passing an explicit `draft` to the constructor + * overrides this — that draft is used for every schema regardless of `$schema`. + * + * @example Use with default configuration (2020-12, shortcircuit on) * ```ts source="./cfWorkerProvider.examples.ts#CfWorkerJsonSchemaValidator_default" * const validator = new CfWorkerJsonSchemaValidator(); * ``` @@ -35,19 +48,22 @@ export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; * ``` */ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { - private shortcircuit: boolean; - private draft: CfWorkerSchemaDraft; + private readonly shortcircuit: boolean; + /** Caller-supplied draft; when set, the `$schema` check is skipped (caller owns dialect). */ + private readonly draft?: CfWorkerSchemaDraft; /** * Create a validator * * @param options - Configuration options * @param options.shortcircuit - If `true`, stop validation after first error (default: `true`) - * @param options.draft - JSON Schema draft version to use (default: `'2020-12'`) + * @param options.draft - JSON Schema draft version to force for every schema. When set, the + * `$schema` check is skipped. When omitted, the provider validates as 2020-12 and rejects + * schemas declaring a different `$schema`. */ constructor(options?: { shortcircuit?: boolean; draft?: CfWorkerSchemaDraft }) { this.shortcircuit = options?.shortcircuit ?? true; - this.draft = options?.draft ?? '2020-12'; + this.draft = options?.draft; } /** @@ -59,8 +75,24 @@ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { * @returns A validator function that validates input data */ getValidator(schema: JsonSchemaType): JsonSchemaValidator { + // Caller forced a draft — use it for everything; do not second-guess by `$schema`. + if ( + this.draft === undefined && + '$schema' in schema && + typeof schema.$schema === 'string' && + !DRAFT_2020_12_URIS.has(schema.$schema.replace(/#$/, '')) + ) { + const declared = schema.$schema.slice(0, 200); + throw new Error( + `JSON Schema declares an unsupported dialect ("$schema": "${declared}"). ` + + `The default validator supports JSON Schema 2020-12 only; pass an explicit ` + + `{ draft } to CfWorkerJsonSchemaValidator to validate other dialects.` + ); + } + + const draft = this.draft ?? '2020-12'; // Cast to the cfworker Schema type - our JsonSchemaType is structurally compatible - const validator = new Validator(schema as ConstructorParameters[0], this.draft, this.shortcircuit); + const validator = new Validator(schema as ConstructorParameters[0], draft, this.shortcircuit); return (input: unknown): JsonSchemaValidatorResult => { const result = validator.validate(input); 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..4e777dc12b --- /dev/null +++ b/packages/core/src/wire/codec.ts @@ -0,0 +1,330 @@ +/** + * 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 { SdkError } from '../errors/sdkErrors.js'; +import { isModernProtocolVersion } from '../shared/protocolEras.js'; +import type { + CallToolResult, + ClientCapabilities, + CreateMessageResult, + CreateMessageResultWithTools, + Implementation, + LoggingLevel, + 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; +} + +/** + * Tri-state validation outcome — the function-only contract for what the + * schema-getter pair (`hasRequestMethod` ⇒ −32601-by-absence; `…Schema(m) + * .parse` throw ⇒ −32602) used to encode in two pieces. Preserving the split + * is the point: collapsing 'absent from this era's registry' into 'invalid' + * would make the in-band fallback chain (validate → on `not-in-era` fall to + * validateInputRequest) treat absence as failure and never fall through. + */ +export type ValidateOutcome = + | { readonly ok: true; readonly value: T } + /** + * Method is spec vocabulary but absent from THIS era's registry. Callers + * map to −32601 (inbound) or a typed local SdkError (outbound), or fall + * through to the in-band validator. + */ + | { readonly ok: false; readonly reason: 'not-in-era' } + /** + * Method is in this era's registry; payload failed the era-exact schema. + * Callers map to −32602. + */ + | { readonly ok: false; readonly reason: 'invalid'; readonly message: string }; + +/** 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. + */ + readonly key: string; + /** A short description of what is wrong with that key (`missing`, or a validation message). */ + readonly problem: string; +} + +/** Material a Client supplies for an era to build its per-request `_meta` envelope from. */ +export interface OutboundEnvelopeMaterial { + readonly protocolVersion: string; + readonly clientInfo: Implementation; + readonly clientCapabilities: ClientCapabilities; + readonly logLevel?: LoggingLevel; +} + +/** 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; + hasInputRequestMethod(method: string): boolean; + + // ── Function-only validation surface ────────────────────────────────── + // The validator-agnostic contract: callers never see a Zod schema, only a + // tri-state outcome. The method-literal overloads carry the typed parse + // result exactly as the (now-deprecated) *Schema getters did. + + /** Era-exact request validation. `not-in-era` ≡ −32601-by-absence; `invalid` ≡ −32602. */ + validateRequest(method: M, raw: unknown): ValidateOutcome; + validateRequest(method: string, raw: unknown): ValidateOutcome; + + /** Era-exact result validation (same registry as `validateRequest`). */ + validateResult(method: M, raw: unknown): ValidateOutcome; + validateResult(method: string, raw: unknown): ValidateOutcome; + + /** Era-exact notification validation. */ + validateNotification(method: M, raw: unknown): ValidateOutcome; + validateNotification(method: string, raw: unknown): ValidateOutcome; + + /** + * In-band (de-JSON-RPC'd) input-request validation — the embedded + * requests a multi-round-trip `input_required` result may carry. Always + * `not-in-era` on the 2025 era (elicitation/sampling/roots are wire + * request methods there). Does NOT grant registry membership. + */ + validateInputRequest(method: M, raw: unknown): ValidateOutcome; + validateInputRequest(method: string, raw: unknown): ValidateOutcome; + + /** In-band bare-response validation answering an embedded input request. */ + validateInputResponse(method: M, raw: unknown): ValidateOutcome; + validateInputResponse(method: string, raw: unknown): ValidateOutcome; + + /** + * Param-conditional `sampling/createMessage` result validation — the one + * spec result whose schema depends on REQUEST params (tools vs no tools). + * The 2025 era owns the with-tools/plain frozen schemas; the 2026 era + * returns `not-in-era` (sampling is in-band there — callers fall through + * to `validateInputResponse`). + */ + samplingResultVariant(hasTools: true, raw: unknown): ValidateOutcome; + samplingResultVariant(hasTools: false, raw: unknown): ValidateOutcome; + samplingResultVariant(hasTools: boolean, raw: unknown): ValidateOutcome; + + /** + * Outbound per-request `_meta` envelope encode. Returns the keyed object + * to merge into `params._meta` on this era, or `undefined` when this era + * carries no per-request envelope (the 2025 era — legacy wire stays + * byte-identical). + */ + outboundEnvelope(material: OutboundEnvelopeMaterial): Readonly> | undefined; + + /** + * Structured envelope validation: maps a `_meta` object to + * self-identifying issues. The 2025 era never requires an envelope and + * always returns `[]`; the 2026 era owns the required-key pre-pass plus + * the wire-exact `RequestMetaEnvelopeSchema` parse. + */ + validateEnvelopeMeta(meta: Readonly>): EnvelopeIssue[]; + + /** + * Per-registration `tools/call` result projection. Two independent + * decisions, both owned here so server-side code never re-derives them: + * + * - SEP-2106 §4.3 TextContent auto-append (EVERY era, value-shape-based): + * when `structuredContent` is a non-object value (array/primitive/ + * `null`) and the handler authored no `type:'text'` block, append + * `{type:'text', text: JSON.stringify(value)}` so consumers that read + * only `content` still receive a rendering. The author opts out by + * returning any `text` block themselves. + * + * - `{result:…}` wrap (2025 era only): wrap as `{result:}` when the + * value is non-object (the 2025 wire shape requires `structuredContent` + * to be an object — a schema-less tool returning `[1,2,3]` would + * otherwise ship wire-illegal bytes) OR when the tool's ADVERTISED + * `outputSchema` has a non-object root (so the result matches the + * `encodeResult('tools/list')` projection of the same tool). Identity on + * the 2026 era — the wire shape carries the natural value directly. + */ + projectCallToolResult(result: CallToolResult, advertisedOutputSchema: Readonly> | undefined): CallToolResult; + + /** + * 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; + + /** + * Outbound error-code mapping (the error half of the stamp seam). A + * handler-thrown `ProtocolError`'s numeric code passes through here on + * its way to the JSON-RPC error response, so per-era wire-code selection + * lives in the codec rather than in handler/funnel code. The current + * mapping is identical on both eras (the `-32002` resource-not-found + * domain code maps to `-32602` Invalid Params on the wire — the + * 2026-07-28 spec MUST, and what the deployed v1.x SDK already emits on + * earlier revisions); the seam is the structural home for any future + * per-era divergence. Unknown codes pass through unchanged. + */ + encodeErrorCode(code: number): number; + + /** + * @deprecated Use {@link validateEnvelopeMeta}. 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): every modern-era revision + * (`>= 2026-07-28`) → the 2026-era codec; every legacy revision (the five + * `SUPPORTED_PROTOCOL_VERSIONS`) and `undefined`/unknown → the 2025-era + * codec (the DV-13 default posture — hand-constructed instances and + * unclassified traffic are legacy-era). This is the same era predicate the + * rest of the SDK uses ({@link isModernProtocolVersion}); a pinned modern + * revision other than the literal '2026-07-28' must still resolve modern. + */ +export function codecForVersion(version: string | undefined): WireCodec { + return version !== undefined && isModernProtocolVersion(version) ? rev2026Codec : rev2025Codec; +} + +/** + * The wire era an edge classification names (Q2 — produced at the + * transport/entry edge; this layer only CONSUMES it). The dispatch funnel no + * longer resolves a codec FROM the classification: era is instance state, and + * a classified inbound message is VALIDATED against the instance era — a + * mismatch is an entry/routing error, never a per-message era switch. 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..b542b6cb06 --- /dev/null +++ b/packages/core/src/wire/rev2025-11-25/codec.ts @@ -0,0 +1,140 @@ +/** + * 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: this module NEVER WRITES 2026 vocabulary + * (`resultType`, `ttlMs`, `cacheScope`, the `_meta` envelope keys) — there is + * no code path that can. `encodeResult` is the identity for every result + * EXCEPT the SEP-2106 `tools/list` projection: when a tool's neutral + * `outputSchema` has a non-object root, the 2025 wire shape requires + * `type:'object'` there, so the codec PROJECTS the public superset down by + * wrapping the advertised schema as `{type:'object', + * properties:{result:}, required:['result']}`. That projection + * narrows neutral→2025-wire; it never adds 2026 vocabulary. + * + * 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 * as z from 'zod/v4'; + +import type { CallToolResult, Result } from '../../types/types.js'; +import type { DecodedResult, EnvelopeIssue, LiftedWireMaterial, OutboundEnvelopeMaterial, ValidateOutcome, WireCodec } from '../codec.js'; +import { appendTextFallbackForNonObject } from '../textFallback.js'; +import { isNonObjectJsonSchemaRoot, wrapOutputSchemaForLegacy } from './legacyWrap.js'; +import { getNotificationSchema, getRequestSchema, getResultSchema, hasNotificationMethod2025, hasRequestMethod2025 } from './registry.js'; +import { CreateMessageResultSchema, CreateMessageResultWithToolsSchema } from './schemas.js'; + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Tri-state wrap of an optional Zod schema lookup (the function-only contract). */ +function triState(schema: z.ZodType | undefined, raw: unknown): ValidateOutcome { + if (schema === undefined) return { ok: false, reason: 'not-in-era' }; + const parsed = schema.safeParse(raw); + return parsed.success ? { ok: true, value: parsed.data } : { ok: false, reason: 'invalid', message: String(parsed.error) }; +} + +const NOT_IN_ERA: ValidateOutcome = { ok: false, reason: 'not-in-era' }; + +/** Whether a `tools/list` entry advertises a non-object `outputSchema` root that needs the SEP-2106 legacy wrap. */ +function toolNeedsLegacyWrap(t: unknown): t is { outputSchema: Record } { + return isPlainObject(t) && isPlainObject(t['outputSchema']) && isNonObjectJsonSchemaRoot(t['outputSchema']); +} + +/** 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, + + // ── Function-only validation surface ── + validateRequest: (method: string, raw: unknown) => triState(getRequestSchema(method), raw), + validateResult: (method: string, raw: unknown) => triState(getResultSchema(method), raw), + validateNotification: (method: string, raw: unknown) => triState(getNotificationSchema(method), raw), + // No in-band input-request vocabulary on this era: elicitation, sampling + // and roots are real wire request methods here (see the registry). + hasInputRequestMethod: (): boolean => false, + validateInputRequest: (): ValidateOutcome => NOT_IN_ERA, + validateInputResponse: (): ValidateOutcome => NOT_IN_ERA, + + // Arrow literals can't carry overload signatures; the cast is sound (the + // boolean dispatches to exactly the schema each overload names). + samplingResultVariant: ((hasTools: boolean, raw: unknown) => + triState(hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema, raw)) as WireCodec['samplingResultVariant'], + + // The 2025 era carries no per-request `_meta` envelope — legacy wire + // bytes stay identical (the never-stamp guarantee, outbound-request half). + outboundEnvelope: (_material: OutboundEnvelopeMaterial): undefined => undefined, + validateEnvelopeMeta: (_meta: Readonly>): EnvelopeIssue[] => [], + + projectCallToolResult(result: CallToolResult, advertisedOutputSchema): CallToolResult { + // Era-agnostic SEP-2106 §4.3 TextContent auto-append first (value-shape-based). + const withText = appendTextFallbackForNonObject(result); + const sc = withText.structuredContent; + if (sc === undefined) return withText; + // SEP-2106 result-side projection. Wrap as `{result:}` when EITHER: + // - the value is non-object (array/primitive/`null`) — REGARDLESS of + // advertised schema, because the 2025 wire shape requires + // `structuredContent` to be an object (a schema-less tool returning + // `[1,2,3]` would otherwise ship wire-illegal bytes), or + // - the advertised `outputSchema` has a non-object root — so the + // result satisfies the wrapped schema this codec's + // `encodeResult('tools/list', …)` advertised for the same tool. + const valueIsNonObject = typeof sc !== 'object' || sc === null || Array.isArray(sc); + const schemaWrapped = advertisedOutputSchema !== undefined && isNonObjectJsonSchemaRoot(advertisedOutputSchema); + if (!valueIsNonObject && !schemaWrapped) return withText; + return { ...withText, structuredContent: { result: sc } }; + }, + + 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: never writes 2026 vocabulary. Identity for + // every result EXCEPT the SEP-2106 `tools/list` projection (see header). + // Copy-on-write: a listing with no non-object outputSchema roots returns + // the same reference (the byte-identity suite pins this). + encodeResult(method: string, result: Result): Result { + if (method !== 'tools/list') return result; + const tools = (result as { tools?: unknown }).tools; + if (!Array.isArray(tools) || !tools.some(t => toolNeedsLegacyWrap(t))) return result; + return { + ...result, + tools: tools.map(t => (toolNeedsLegacyWrap(t) ? { ...t, outputSchema: wrapOutputSchemaForLegacy(t.outputSchema) } : t)) + } as Result; + }, + + // The −32002 resource-not-found domain code maps to −32602 on the wire on + // this era too (matching what the deployed v1.x SDK already emits — this + // is not a behavior change for v1.x peers). There is deliberately no era + // branch that preserves −32002. + encodeErrorCode: (code: number): number => (code === -32_002 ? -32_602 : code), + + // 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/legacyWrap.ts b/packages/core/src/wire/rev2025-11-25/legacyWrap.ts new file mode 100644 index 0000000000..9e80dd3c31 --- /dev/null +++ b/packages/core/src/wire/rev2025-11-25/legacyWrap.ts @@ -0,0 +1,113 @@ +/** + * SEP-2106 legacy `outputSchema` wrap helpers (2025-era projection only). + * + * The neutral / 2026-07-28 model lets a tool's `outputSchema` carry any JSON + * Schema root. The 2025-11-25 wire shape requires `type:'object'` at the root, + * so when an era-blind handler advertises a non-object root, the 2025 codec's + * `encodeResult('tools/list', …)` projects it down to + * `{type:'object', properties:{result:}, required:['result']}`, and + * `projectCallToolResult` wraps the matching `structuredContent` as + * `{result:}`. The 2026 codec's projections are the identity. + * + * These helpers are wire-layer property — they exist so the projection can + * live behind {@link WireCodec.encodeResult} / {@link WireCodec.projectCallToolResult} + * and never be re-derived in shared/ or server-side code. + */ + +/** + * Whether a JSON Schema's root is non-object: either an explicit non-object + * `type`, or a typeless root such as `{anyOf:[…]}`. Object-shaped typeless + * roots that the schema-conversion layer can prove are objects are stamped + * `type:'object'` upstream, so they reach this predicate as object roots. + */ +export function isNonObjectJsonSchemaRoot(json: Readonly>): boolean { + return json['type'] !== 'object'; +} + +/** + * Keyword-position keys whose values are instance data (not subschemas). A + * `{$ref:…}` appearing inside one is a literal value, not a JSON Pointer to + * rewrite. Only consulted when the current object is in keyword position — + * a PROPERTY named `default`/`const` (under `properties`/`$defs`/…) is a name + * position whose value IS a subschema and is recursed into. + */ +const REF_REWRITE_DATA_POSITION_KEYS: ReadonlySet = new Set(['const', 'enum', 'default', 'examples']); + +/** + * Keyword-position keys whose value is a name→subschema map. Entries inside + * such a map are in NAME position: their keys are author-chosen property + * names (which may collide with JSON Schema keywords), their values are + * subschemas to recurse into. + */ +const REF_REWRITE_NAME_MAP_KEYS: ReadonlySet = new Set([ + 'properties', + 'patternProperties', + '$defs', + 'definitions', + 'dependentSchemas' +]); + +/** + * Wrap a non-object output schema in the 2025-era envelope: + * `{type:'object', properties:{result:}, required:['result']}`. + * + * Same-document `$ref` / `$dynamicRef` JSON Pointers inside the natural schema + * (e.g. `#/properties/foo` produced by zod for de-duplicated/recursive types) + * are rewritten to account for the new `#/properties/result` root: bare `#` → + * `#/properties/result`, `#/…` → `#/properties/result/…`. Cross-document refs + * (anything not starting with `#`) are left untouched. + * + * The rewrite is position-aware: data-valued keywords + * (`const`/`enum`/`default`/`examples`) in keyword position are NOT descended + * into; the same names appearing as property names under + * `properties`/`patternProperties`/`$defs`/`definitions`/`dependentSchemas` + * ARE descended into (they're subschemas). The rewrite is also `$id`-scoped: + * if the natural root carries `$id` no pointer is rewritten (same-document + * refs inside resolve against the embedded `$id` base, not the wrapper root), + * and any subtree that establishes its own `$id` is left untouched for the + * same reason. + */ +export function wrapOutputSchemaForLegacy(natural: Readonly>): Record { + // A root `$schema` is hoisted to the wrapper root: it's a document-level + // dialect declaration and the SEP-1613 dialect checks (both built-in + // providers) only inspect the root, so leaving it under `properties.result` + // would make a non-2020-12 schema pass the dialect check on the 2025 + // projection while the same tool is rejected on the 2026 era. + const $schema = typeof natural['$schema'] === 'string' ? natural['$schema'] : undefined; + // `$id` at the natural root: every same-document `#/…` ref inside resolves + // against that base URI, not against the wrapper root — skip the rewrite. + if (natural['$id'] !== undefined) { + return { ...($schema !== undefined && { $schema }), type: 'object', properties: { result: natural }, required: ['result'] }; + } + const rewriteRefs = (node: unknown, parentIsNameMap: boolean): unknown => { + if (Array.isArray(node)) return node.map(item => rewriteRefs(item, false)); + if (node === null || typeof node !== 'object') return node; + // A nested `$id` establishes its own resolution base for the subtree — + // same-document refs inside are no longer relative to the wrapper root. + // Only applies in keyword position (a property NAMED `$id` is just a name). + if (!parentIsNameMap && (node as Record)['$id'] !== undefined) return node; + const out: Record = {}; + for (const [k, v] of Object.entries(node)) { + if (parentIsNameMap) { + // Name position: `k` is an author-chosen property/def name, `v` is a + // subschema in keyword position. Never treat `k` as a keyword here. + out[k] = rewriteRefs(v, false); + } else if ((k === '$ref' || k === '$dynamicRef') && typeof v === 'string') { + out[k] = v === '#' ? '#/properties/result' : v.startsWith('#/') ? `#/properties/result${v.slice(1)}` : v; + } else if (REF_REWRITE_DATA_POSITION_KEYS.has(k)) { + out[k] = v; + } else if (REF_REWRITE_NAME_MAP_KEYS.has(k)) { + out[k] = rewriteRefs(v, true); + } else { + out[k] = rewriteRefs(v, false); + } + } + return out; + }; + return { + ...($schema !== undefined && { $schema }), + type: 'object', + properties: { result: rewriteRefs(natural, false) }, + required: ['result'] + }; +} 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..478485fa23 --- /dev/null +++ b/packages/core/src/wire/rev2025-11-25/registry.ts @@ -0,0 +1,224 @@ +/** + * 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 type { NotificationMethod, NotificationTypeMap, RequestMethod, RequestTypeMap, ResultTypeMap } from '../../types/types.js'; +import type { ClientNotificationSchema, ClientRequestSchema, ServerNotificationSchema, ServerRequestSchema } from './schemas.js'; +import { + CallToolRequestSchema, + CallToolResultSchema, + CancelledNotificationSchema, + CancelTaskRequestSchema, + CompleteRequestSchema, + CompleteResultSchema, + CreateMessageRequestSchema, + CreateMessageResultWithToolsSchema, + ElicitationCompleteNotificationSchema, + ElicitRequestSchema, + ElicitResultSchema, + EmptyResultSchema, + GetPromptRequestSchema, + GetPromptResultSchema, + GetTaskPayloadRequestSchema, + GetTaskRequestSchema, + InitializedNotificationSchema, + InitializeRequestSchema, + InitializeResultSchema, + ListPromptsRequestSchema, + ListPromptsResultSchema, + ListResourcesRequestSchema, + ListResourcesResultSchema, + ListResourceTemplatesRequestSchema, + ListResourceTemplatesResultSchema, + ListRootsRequestSchema, + ListRootsResultSchema, + ListTasksRequestSchema, + ListToolsRequestSchema, + ListToolsResultSchema, + LoggingMessageNotificationSchema, + PingRequestSchema, + ProgressNotificationSchema, + PromptListChangedNotificationSchema, + ReadResourceRequestSchema, + ReadResourceResultSchema, + ResourceListChangedNotificationSchema, + ResourceUpdatedNotificationSchema, + RootsListChangedNotificationSchema, + SetLevelRequestSchema, + SubscribeRequestSchema, + TaskStatusNotificationSchema, + ToolListChangedNotificationSchema, + UnsubscribeRequestSchema +} 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..12041e2bb5 --- /dev/null +++ b/packages/core/src/wire/rev2025-11-25/schemas.ts @@ -0,0 +1,2229 @@ +/** + * Complete frozen 2025-11-25 wire schemas. Self-contained — no imports from + * the public/neutral types/schemas.ts. The neutral layer is the public-API + * superset and is free to evolve (e.g., SEP-2106 widening); this file is the + * 2025 wire-parse contract (Q10-L2 byte-identity) and is BEHAVIOR-FROZEN. + * + * This is the era's complete frozen wire-parse contract — both the 2025-only + * delta (the deprecated task family, the era role unions) AND frozen copies of + * every era-shared shape (Tool, CallToolResult, Initialize*, ContentBlock, + * prompts/resources/completion/elicitation, …). The 2026-era codec + * (`wire/rev2026-07-28/`) is symmetrically self-contained in the same way. + * + * The 2025-only delta (the task message surface, restored types-only by #2248 + * for interop with task-capable 2025 peers) is parsed ONLY through this era's + * registry; the deprecated Task* schemas also live (marked `@deprecated`) in + * the neutral schema layer so the public types stay nameable without a + * cross-layer import — nameability is constant, runtime availability is + * version-keyed — but appear in no API signature. Q1 increment 2 — deletions + * are physical: the + * 2026-era REGISTRY has no Task* methods (its frozen building-block copies do + * carry the deprecated Task* sub-schemas by composition — soft contamination, + * tracked for anchor-exactness adjudication). + * + * The only cross-layer dependency is `import type { JSONObject, JSONValue }` + * from the neutral types barrel — pure structural type aliases with no parse + * behavior. No runtime schema is shared with the neutral layer. + */ +import * as z from 'zod/v4'; + +import type { JSONObject, JSONValue } from '../../types/types.js'; + +/* ─────────────────────────────────────────────────────────────────────────── + * Building blocks + * ─────────────────────────────────────────────────────────────────────────── */ + +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)]) +); +export const JSONObjectSchema: z.ZodType = z.record(z.string(), JSONValueSchema); + +/** + * A progress token, used to associate progress notifications with the original request. + */ +export const ProgressTokenSchema = z.union([z.string(), z.number().int()]); + +/** + * An opaque token used to represent a cursor for pagination. + */ +export const CursorSchema = z.string(); + +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ +export const TaskMetadataSchema = z.object({ + ttl: z.number().optional() +}); + +/** + * 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() +}); + +export const RequestMetaSchema = 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(), + /** + * If specified, this request is related to the provided task. + */ + 'io.modelcontextprotocol/related-task': RelatedTaskMetadataSchema.optional() +}); + +/** + * Common params for any request. + */ +export const BaseRequestParamsSchema = z.object({ + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta: RequestMetaSchema.optional() +}); + +/** + * 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({ + /** + * If specified, the caller is requesting task-augmented execution for this request. + * The request will return a `CreateTaskResult` immediately, and the actual result can be + * retrieved later via `tasks/result`. + * + * Task augmentation is subject to capability negotiation - receivers MUST declare support + * for task augmentation of specific request types in their capabilities. + */ + task: TaskMetadataSchema.optional() +}); + +export const RequestSchema = z.object({ + method: z.string(), + params: BaseRequestParamsSchema.loose().optional() +}); + +export const NotificationsParamsSchema = z.object({ + /** + * 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() +}); + +export const NotificationSchema = z.object({ + method: z.string(), + params: NotificationsParamsSchema.loose().optional() +}); + +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() +}); + +/** + * A uniquely identifying ID for a request in JSON-RPC. + */ +export const RequestIdSchema = z.union([z.string(), z.number().int()]); + +/* Empty result */ +/** + * A response that indicates success but carries no data. + */ +export const EmptyResultSchema = ResultSchema.strict(); + +export const CancelledNotificationParamsSchema = NotificationsParamsSchema.extend({ + /** + * The ID of the request to cancel. + * + * This MUST correspond to the ID of a request previously issued in the same direction. + */ + requestId: RequestIdSchema.optional(), + /** + * An optional string describing the reason for the cancellation. This MAY be logged or presented to the user. + */ + reason: z.string().optional() +}); +/* Cancellation */ +/** + * This notification can be sent by either side to indicate that it is cancelling a previously-issued request. + * + * The 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. + * + * This notification indicates that the result will be unused, so any associated processing SHOULD cease. + * + * A client MUST NOT attempt to cancel its {@linkcode InitializeRequest | initialize} request. + */ +export const CancelledNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/cancelled'), + params: CancelledNotificationParamsSchema +}); + +/* Base Metadata */ +/** + * Icon schema for use in {@link Tool | tools}, {@link Prompt | prompts}, {@link Resource | resources}, and {@link Implementation | implementations}. + */ +export const IconSchema = z.object({ + /** + * URL or data URI for the icon. + */ + src: z.string(), + /** + * Optional MIME type for the icon. + */ + mimeType: z.string().optional(), + /** + * Optional array of strings that specify sizes at which the icon can be used. + * Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for scalable formats like SVG. + * + * If not provided, the client should assume that the icon can be used at any size. + */ + sizes: z.array(z.string()).optional(), + /** + * Optional specifier for the theme this icon is designed for. `light` indicates + * the icon is designed to be used with a light background, and `dark` indicates + * the icon is designed to be used with a dark background. + * + * If not provided, the client should assume the icon can be used with any theme. + */ + theme: z.enum(['light', 'dark']).optional() +}); + +/** + * Base schema to add `icons` property. + * + */ +export const IconsSchema = z.object({ + /** + * Optional set of sized icons that the client can display in a user interface. + * + * Clients that support rendering icons MUST support at least the following MIME types: + * - `image/png` - PNG images (safe, universal compatibility) + * - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + * + * Clients that support rendering icons SHOULD also support: + * - `image/svg+xml` - SVG images (scalable but requires security precautions) + * - `image/webp` - WebP images (modern, efficient format) + */ + icons: z.array(IconSchema).optional() +}); + +/** + * Base metadata interface for common properties across {@link Resource | resources}, {@link Tool | tools}, {@link Prompt | prompts}, and {@link Implementation | implementations}. + */ +export const BaseMetadataSchema = z.object({ + /** Intended for programmatic or logical use, but used as a display name in past specs or fallback */ + name: z.string(), + /** + * Intended for UI and end-user contexts — optimized to be human-readable and easily understood, + * even by those unfamiliar with domain-specific terminology. + * + * If not provided, the `name` should be used for display (except for `Tool`, + * where `annotations.title` should be given precedence over using `name`, + * if present). + */ + title: z.string().optional() +}); + +/* Initialization */ +/** + * Describes the name and version of an MCP implementation. + */ +export const ImplementationSchema = BaseMetadataSchema.extend({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + version: z.string(), + /** + * An optional URL of the website for this implementation. + */ + websiteUrl: z.string().optional(), + + /** + * An optional human-readable description of what this implementation does. + * + * This can be used by clients or servers to provide context about their purpose + * and capabilities. For example, a server might describe the types of resources + * or tools it provides, while a client might describe its intended use case. + */ + description: z.string().optional() +}); + +const FormElicitationCapabilitySchema = z.intersection( + z.object({ + applyDefaults: z.boolean().optional() + }), + JSONObjectSchema +); + +const ElicitationCapabilitySchema = z.preprocess( + value => { + if (value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record).length === 0) { + return { form: {} }; + } + return value; + }, + z.intersection( + z.object({ + form: FormElicitationCapabilitySchema.optional(), + url: JSONObjectSchema.optional() + }), + JSONObjectSchema.optional() + ) +); + +/** + * 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({ + /** + * Present if the client supports listing tasks. + */ + list: JSONObjectSchema.optional(), + /** + * Present if the client supports cancelling tasks. + */ + cancel: JSONObjectSchema.optional(), + /** + * Capabilities for task creation on specific request types. + */ + requests: z + .looseObject({ + /** + * Task support for sampling requests. + */ + sampling: z + .looseObject({ + createMessage: JSONObjectSchema.optional() + }) + .optional(), + /** + * Task support for elicitation requests. + */ + elicitation: z + .looseObject({ + create: JSONObjectSchema.optional() + }) + .optional() + }) + .optional() +}); + +/** + * 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({ + /** + * Present if the server supports listing tasks. + */ + list: JSONObjectSchema.optional(), + /** + * Present if the server supports cancelling tasks. + */ + cancel: JSONObjectSchema.optional(), + /** + * Capabilities for task creation on specific request types. + */ + requests: z + .looseObject({ + /** + * Task support for tool requests. + */ + tools: z + .looseObject({ + call: JSONObjectSchema.optional() + }) + .optional() + }) + .optional() +}); + +/** + * 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. + */ +export const ClientCapabilitiesSchema = z.object({ + /** + * Experimental, non-standard capabilities that the client supports. + */ + experimental: z.record(z.string(), JSONObjectSchema).optional(), + /** + * Present if the client supports sampling from an LLM. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ + sampling: z + .object({ + /** + * Present if the client supports context inclusion via `includeContext` parameter. + * If not declared, servers SHOULD only use `includeContext: "none"` (or omit it). + */ + context: JSONObjectSchema.optional(), + /** + * Present if the client supports tool use via `tools` and `toolChoice` parameters. + */ + tools: JSONObjectSchema.optional() + }) + .optional(), + /** + * Present if the client supports eliciting user input. + */ + elicitation: ElicitationCapabilitySchema.optional(), + /** + * Present if the client supports listing roots. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to passing paths via + * tool parameters, resource URIs, or configuration. + */ + roots: z + .object({ + /** + * Whether the client supports issuing notifications for changes to the roots list. + */ + listChanged: z.boolean().optional() + }) + .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(), + /** + * Extensions that the client supports. Keys are extension identifiers (vendor-prefix/extension-name). + */ + extensions: z.record(z.string(), JSONObjectSchema).optional() +}); + +export const InitializeRequestParamsSchema = BaseRequestParamsSchema.extend({ + /** + * The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well. + */ + protocolVersion: z.string(), + capabilities: ClientCapabilitiesSchema, + clientInfo: ImplementationSchema +}); +/** + * This request is sent from the client to the server when it first connects, asking it to begin initialization. + */ +export const InitializeRequestSchema = RequestSchema.extend({ + method: z.literal('initialize'), + params: InitializeRequestParamsSchema +}); + +/** + * 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. + */ +export const ServerCapabilitiesSchema = z.object({ + /** + * Experimental, non-standard capabilities that the server supports. + */ + experimental: z.record(z.string(), JSONObjectSchema).optional(), + /** + * Present if the server supports sending log messages to the client. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to stderr logging + * (STDIO servers) or OpenTelemetry. + */ + logging: JSONObjectSchema.optional(), + /** + * Present if the server supports sending completions to the client. + */ + completions: JSONObjectSchema.optional(), + /** + * Present if the server offers any prompt templates. + */ + prompts: z + .object({ + /** + * Whether this server supports issuing notifications for changes to the prompt list. + */ + listChanged: z.boolean().optional() + }) + .optional(), + /** + * Present if the server offers any resources to read. + */ + resources: z + .object({ + /** + * Whether this server supports clients subscribing to resource updates. + */ + subscribe: z.boolean().optional(), + + /** + * Whether this server supports issuing notifications for changes to the resource list. + */ + listChanged: z.boolean().optional() + }) + .optional(), + /** + * Present if the server offers any tools to call. + */ + tools: z + .object({ + /** + * Whether this server supports issuing notifications for changes to the tool list. + */ + listChanged: z.boolean().optional() + }) + .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(), + /** + * Extensions that the server supports. Keys are extension identifiers (vendor-prefix/extension-name). + */ + extensions: z.record(z.string(), JSONObjectSchema).optional() +}); + +/** + * After receiving an initialize request from the client, the server sends this response. + */ +export const InitializeResultSchema = ResultSchema.extend({ + /** + * 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. + */ + protocolVersion: z.string(), + capabilities: ServerCapabilitiesSchema, + serverInfo: ImplementationSchema, + /** + * Instructions describing how to use the server and its features. + * + * This 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. + */ + instructions: z.string().optional() +}); + +/** + * This notification is sent from the client to the server after initialization has finished. + */ +export const InitializedNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/initialized'), + params: NotificationsParamsSchema.optional() +}); + +/* Ping */ +/** + * 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. + */ +export const PingRequestSchema = RequestSchema.extend({ + method: z.literal('ping'), + params: BaseRequestParamsSchema.optional() +}); + +/* Progress notifications */ +export const ProgressSchema = z.object({ + /** + * The progress thus far. This should increase every time progress is made, even if the total is unknown. + */ + progress: z.number(), + /** + * Total number of items to process (or total progress required), if known. + */ + total: z.optional(z.number()), + /** + * An optional message describing the current progress. + */ + message: z.optional(z.string()) +}); + +export const ProgressNotificationParamsSchema = z.object({ + ...NotificationsParamsSchema.shape, + ...ProgressSchema.shape, + /** + * The progress token which was given in the initial request, used to associate this notification with the request that is proceeding. + */ + progressToken: ProgressTokenSchema +}); +/** + * An out-of-band notification used to inform the receiver of a progress update for a long-running request. + * + * @category notifications/progress + */ +export const ProgressNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/progress'), + params: ProgressNotificationParamsSchema +}); + +export const PaginatedRequestParamsSchema = BaseRequestParamsSchema.extend({ + /** + * An opaque token representing the current pagination position. + * If provided, the server should return results starting after this cursor. + */ + cursor: CursorSchema.optional() +}); + +/* Pagination */ +export const PaginatedRequestSchema = RequestSchema.extend({ + params: PaginatedRequestParamsSchema.optional() +}); + +export const PaginatedResultSchema = ResultSchema.extend({ + /** + * An opaque token representing the pagination position after the last returned result. + * If present, there may be more results available. + */ + nextCursor: CursorSchema.optional() +}); + +/* Resources */ +/** + * The contents of a specific resource or sub-resource. + */ +export const ResourceContentsSchema = z.object({ + /** + * The URI of this resource. + */ + uri: z.string(), + /** + * The MIME type of this resource, if known. + */ + mimeType: z.optional(z.string()), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +export const TextResourceContentsSchema = ResourceContentsSchema.extend({ + /** + * The text of the item. This must only be set if the item can actually be represented as text (not binary data). + */ + text: z.string() +}); + +/** + * A Zod schema for validating Base64 strings that is more performant and + * robust for very large inputs than the default regex-based check. It avoids + * stack overflows by using the native `atob` function for validation. + */ +const Base64Schema = z.string().refine( + val => { + try { + // atob throws a DOMException if the string contains characters + // that are not part of the Base64 character set. + atob(val); + return true; + } catch { + return false; + } + }, + { message: 'Invalid Base64 string' } +); + +export const BlobResourceContentsSchema = ResourceContentsSchema.extend({ + /** + * A base64-encoded string representing the binary data of the item. + */ + blob: Base64Schema +}); + +/** + * The sender or recipient of messages and data in a conversation. + */ +export const RoleSchema = z.enum(['user', 'assistant']); + +/** + * Optional annotations providing clients additional context about a resource. + */ +export const AnnotationsSchema = z.object({ + /** + * Intended audience(s) for the resource. + */ + audience: z.array(RoleSchema).optional(), + + /** + * Importance hint for the resource, from 0 (least) to 1 (most). + */ + priority: z.number().min(0).max(1).optional(), + + /** + * ISO 8601 timestamp for the most recent modification. + */ + lastModified: z.iso.datetime({ offset: true }).optional() +}); + +/** + * A known resource that the server is capable of reading. + */ +export const ResourceSchema = z.object({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + /** + * The URI of this resource. + */ + uri: z.string(), + + /** + * A description of what this resource represents. + * + * This 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. + */ + description: z.optional(z.string()), + + /** + * The MIME type of this resource, if known. + */ + mimeType: z.optional(z.string()), + + /** + * The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + * + * This can be used by Hosts to display file sizes and estimate context window usage. + */ + size: z.optional(z.number()), + + /** + * Optional annotations for the client. + */ + annotations: AnnotationsSchema.optional(), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.optional(z.looseObject({})) +}); + +/** + * A template description for resources available on the server. + */ +export const ResourceTemplateSchema = z.object({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + /** + * A URI template (according to RFC 6570) that can be used to construct resource URIs. + */ + uriTemplate: z.string(), + + /** + * A description of what this template is for. + * + * This 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. + */ + description: z.optional(z.string()), + + /** + * 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. + */ + mimeType: z.optional(z.string()), + + /** + * Optional annotations for the client. + */ + annotations: AnnotationsSchema.optional(), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.optional(z.looseObject({})) +}); + +/** + * Sent from the client to request a list of resources the server has. + */ +export const ListResourcesRequestSchema = PaginatedRequestSchema.extend({ + method: z.literal('resources/list') +}); + +/** + * The server's response to a {@linkcode ListResourcesRequest | resources/list} request from the client. + */ +export const ListResourcesResultSchema = PaginatedResultSchema.extend({ + resources: z.array(ResourceSchema) +}); + +/** + * Sent from the client to request a list of resource templates the server has. + */ +export const ListResourceTemplatesRequestSchema = PaginatedRequestSchema.extend({ + method: z.literal('resources/templates/list') +}); + +/** + * The server's response to a {@linkcode ListResourceTemplatesRequest | resources/templates/list} request from the client. + */ +export const ListResourceTemplatesResultSchema = PaginatedResultSchema.extend({ + resourceTemplates: z.array(ResourceTemplateSchema) +}); + +export const ResourceRequestParamsSchema = BaseRequestParamsSchema.extend({ + /** + * The URI of the resource to read. The URI can use any protocol; it is up to the server how to interpret it. + * + * @format uri + */ + uri: z.string() +}); + +/** + * Parameters for a {@linkcode ReadResourceRequest | resources/read} request. + */ +export const ReadResourceRequestParamsSchema = ResourceRequestParamsSchema; + +/** + * Sent from the client to the server, to read a specific resource URI. + */ +export const ReadResourceRequestSchema = RequestSchema.extend({ + method: z.literal('resources/read'), + params: ReadResourceRequestParamsSchema +}); + +/** + * The server's response to a {@linkcode ReadResourceRequest | resources/read} request from the client. + */ +export const ReadResourceResultSchema = ResultSchema.extend({ + contents: z.array(z.union([TextResourceContentsSchema, BlobResourceContentsSchema])) +}); + +/** + * 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. + */ +export const ResourceListChangedNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/resources/list_changed'), + params: NotificationsParamsSchema.optional() +}); + +export const SubscribeRequestParamsSchema = ResourceRequestParamsSchema; +/** + * Sent from the client to request `resources/updated` notifications from the server whenever a particular resource changes. + */ +export const SubscribeRequestSchema = RequestSchema.extend({ + method: z.literal('resources/subscribe'), + params: SubscribeRequestParamsSchema +}); + +export const UnsubscribeRequestParamsSchema = ResourceRequestParamsSchema; +/** + * Sent from the client to request cancellation of {@linkcode ResourceUpdatedNotification | resources/updated} notifications from the server. This should follow a previous {@linkcode SubscribeRequest | resources/subscribe} request. + */ +export const UnsubscribeRequestSchema = RequestSchema.extend({ + method: z.literal('resources/unsubscribe'), + params: UnsubscribeRequestParamsSchema +}); + +/** + * Parameters for a {@linkcode ResourceUpdatedNotification | notifications/resources/updated} notification. + */ +export const ResourceUpdatedNotificationParamsSchema = NotificationsParamsSchema.extend({ + /** + * The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to. + */ + uri: z.string() +}); + +/** + * 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 {@linkcode SubscribeRequest | resources/subscribe} request. + */ +export const ResourceUpdatedNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/resources/updated'), + params: ResourceUpdatedNotificationParamsSchema +}); + +/* Prompts */ +/** + * Describes an argument that a prompt can accept. + */ +export const PromptArgumentSchema = z.object({ + /** + * The name of the argument. + */ + name: z.string(), + /** + * A human-readable description of the argument. + */ + description: z.optional(z.string()), + /** + * Whether this argument must be provided. + */ + required: z.optional(z.boolean()) +}); + +/** + * A prompt or prompt template that the server offers. + */ +export const PromptSchema = z.object({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + /** + * An optional description of what this prompt provides + */ + description: z.optional(z.string()), + /** + * A list of arguments to use for templating the prompt. + */ + arguments: z.optional(z.array(PromptArgumentSchema)), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.optional(z.looseObject({})) +}); + +/** + * Sent from the client to request a list of prompts and prompt templates the server has. + */ +export const ListPromptsRequestSchema = PaginatedRequestSchema.extend({ + method: z.literal('prompts/list') +}); + +/** + * The server's response to a {@linkcode ListPromptsRequest | prompts/list} request from the client. + */ +export const ListPromptsResultSchema = PaginatedResultSchema.extend({ + prompts: z.array(PromptSchema) +}); + +/** + * Parameters for a {@linkcode GetPromptRequest | prompts/get} request. + */ +export const GetPromptRequestParamsSchema = BaseRequestParamsSchema.extend({ + /** + * The name of the prompt or prompt template. + */ + name: z.string(), + /** + * Arguments to use for templating the prompt. + */ + arguments: z.record(z.string(), z.string()).optional() +}); +/** + * Used by the client to get a prompt provided by the server. + */ +export const GetPromptRequestSchema = RequestSchema.extend({ + method: z.literal('prompts/get'), + params: GetPromptRequestParamsSchema +}); + +/** + * Text provided to or from an LLM. + */ +export const TextContentSchema = z.object({ + type: z.literal('text'), + /** + * The text content of the message. + */ + text: z.string(), + + /** + * Optional annotations for the client. + */ + annotations: AnnotationsSchema.optional(), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** + * An image provided to or from an LLM. + */ +export const ImageContentSchema = z.object({ + type: z.literal('image'), + /** + * The base64-encoded image data. + */ + data: Base64Schema, + /** + * The MIME type of the image. Different providers may support different image types. + */ + mimeType: z.string(), + + /** + * Optional annotations for the client. + */ + annotations: AnnotationsSchema.optional(), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** + * Audio content provided to or from an LLM. + */ +export const AudioContentSchema = z.object({ + type: z.literal('audio'), + /** + * The base64-encoded audio data. + */ + data: Base64Schema, + /** + * The MIME type of the audio. Different providers may support different audio types. + */ + mimeType: z.string(), + + /** + * Optional annotations for the client. + */ + annotations: AnnotationsSchema.optional(), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** + * A tool call request from an assistant (LLM). + * Represents the assistant's request to use a tool. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ +export const ToolUseContentSchema = z.object({ + type: z.literal('tool_use'), + /** + * The name of the tool to invoke. + * Must match a tool name from the request's tools array. + */ + name: z.string(), + /** + * Unique identifier for this tool call. + * Used to correlate with `ToolResultContent` in subsequent messages. + */ + id: z.string(), + /** + * Arguments to pass to the tool. + * Must conform to the tool's `inputSchema`. + */ + input: z.record(z.string(), z.unknown()), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** + * The contents of a resource, embedded into a prompt or tool call result. + */ +export const EmbeddedResourceSchema = z.object({ + type: z.literal('resource'), + resource: z.union([TextResourceContentsSchema, BlobResourceContentsSchema]), + /** + * Optional annotations for the client. + */ + annotations: AnnotationsSchema.optional(), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** + * A resource that the server is capable of reading, included in a prompt or tool call result. + * + * Note: resource links returned by tools are not guaranteed to appear in the results of {@linkcode ListResourcesRequest | resources/list} requests. + */ +export const ResourceLinkSchema = ResourceSchema.extend({ + type: z.literal('resource_link') +}); + +/** + * A content block that can be used in prompts and tool results. + */ +export const ContentBlockSchema = z.union([ + TextContentSchema, + ImageContentSchema, + AudioContentSchema, + ResourceLinkSchema, + EmbeddedResourceSchema +]); + +/** + * Describes a message returned as part of a prompt. + */ +export const PromptMessageSchema = z.object({ + role: RoleSchema, + content: ContentBlockSchema +}); + +/** + * The server's response to a {@linkcode GetPromptRequest | prompts/get} request from the client. + */ +export const GetPromptResultSchema = ResultSchema.extend({ + /** + * An optional description for the prompt. + */ + description: z.string().optional(), + messages: z.array(PromptMessageSchema) +}); + +/** + * 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. + */ +export const PromptListChangedNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/prompts/list_changed'), + params: NotificationsParamsSchema.optional() +}); + +/* Tools */ +/** + * Additional properties describing a `Tool` to clients. + * + * NOTE: all properties in {@linkcode ToolAnnotations} are **hints**. + * They are not guaranteed to provide a faithful description of + * tool behavior (including descriptive properties like `title`). + * + * Clients should never make tool use decisions based on `ToolAnnotations` + * received from untrusted servers. + */ +export const ToolAnnotationsSchema = z.object({ + /** + * A human-readable title for the tool. + */ + title: z.string().optional(), + + /** + * If `true`, the tool does not modify its environment. + * + * Default: `false` + */ + readOnlyHint: z.boolean().optional(), + + /** + * If `true`, the tool may perform destructive updates to its environment. + * If `false`, the tool performs only additive updates. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: `true` + */ + destructiveHint: z.boolean().optional(), + + /** + * If `true`, calling the tool repeatedly with the same arguments + * will have no additional effect on its environment. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: `false` + */ + idempotentHint: z.boolean().optional(), + + /** + * If `true`, this tool may interact with an "open world" of external + * entities. If `false`, the tool's domain of interaction is closed. + * For example, the world of a web search tool is open, whereas that + * of a memory tool is not. + * + * Default: `true` + */ + openWorldHint: z.boolean().optional() +}); + +/** + * Execution-related properties for a tool. + */ +export const ToolExecutionSchema = z.object({ + /** + * Indicates the tool's preference for task-augmented execution. + * - `"required"`: Clients MUST invoke the tool as a task + * - `"optional"`: Clients MAY invoke the tool as a task or normal request + * - `"forbidden"`: Clients MUST NOT attempt to invoke the tool as a task + * + * If not present, defaults to `"forbidden"`. + */ + taskSupport: z.enum(['required', 'optional', 'forbidden']).optional() +}); + +/** + * Definition for a tool the client can call. + */ +export const ToolSchema = z.object({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + /** + * A human-readable description of the tool. + */ + description: z.string().optional(), + /** + * A JSON Schema 2020-12 object defining the expected parameters for the tool. + * Must have `type: 'object'` at the root level per MCP spec. + */ + inputSchema: z + .object({ + type: z.literal('object'), + properties: z.record(z.string(), JSONValueSchema).optional(), + required: z.array(z.string()).optional() + }) + .catchall(z.unknown()), + /** + * An optional JSON Schema 2020-12 object defining the structure of the tool's output + * returned in the `structuredContent` field of a `CallToolResult`. + * Must have `type: 'object'` at the root level per MCP spec. + */ + outputSchema: z + .object({ + type: z.literal('object'), + properties: z.record(z.string(), JSONValueSchema).optional(), + required: z.array(z.string()).optional() + }) + .catchall(z.unknown()) + .optional(), + /** + * Optional additional tool information. + */ + annotations: ToolAnnotationsSchema.optional(), + /** + * Execution-related properties for this tool. + */ + execution: ToolExecutionSchema.optional(), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** + * Sent from the client to request a list of tools the server has. + */ +export const ListToolsRequestSchema = PaginatedRequestSchema.extend({ + method: z.literal('tools/list') +}); + +/** + * The server's response to a {@linkcode ListToolsRequest | tools/list} request from the client. + */ +export const ListToolsResultSchema = PaginatedResultSchema.extend({ + tools: z.array(ToolSchema) +}); + +/** + * The server's response to a tool call. + */ +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. + * Required on the wire per the specification (it may be an empty array). + */ + content: z.array(ContentBlockSchema), + + /** + * An object containing structured tool output. + * + * If the `Tool` defines an outputSchema, this field MUST be present in the result, and contain a JSON object that matches the schema. + */ + structuredContent: z.record(z.string(), z.unknown()).optional(), + + /** + * Whether the tool call ended in an error. + * + * If not set, this is assumed to be `false` (the call was successful). + * + * Any errors that originate from the tool SHOULD be reported inside the result + * object, with `isError` set to `true`, _not_ as an MCP protocol-level error + * response. Otherwise, the LLM would not be able to see that an error occurred + * and self-correct. + * + * However, any errors in _finding_ the tool, an error indicating that the + * server does not support tool calls, or any other exceptional conditions, + * should be reported as an MCP error response. + */ + isError: z.boolean().optional() +}); + +/** + * Parameters for a `tools/call` request. + */ +export const CallToolRequestParamsSchema = TaskAugmentedRequestParamsSchema.extend({ + /** + * The name of the tool to call. + */ + name: z.string(), + /** + * Arguments to pass to the tool. + */ + arguments: z.record(z.string(), z.unknown()).optional() +}); + +/** + * Used by the client to invoke a tool provided by the server. + */ +export const CallToolRequestSchema = RequestSchema.extend({ + method: z.literal('tools/call'), + params: CallToolRequestParamsSchema +}); + +/** + * 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. + */ +export const ToolListChangedNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/tools/list_changed'), + params: NotificationsParamsSchema.optional() +}); + +/* Logging */ +/** + * The severity of a log message. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to stderr logging + * (STDIO servers) or OpenTelemetry. + */ +export const LoggingLevelSchema = z.enum(['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency']); + +/** + * Parameters for a `logging/setLevel` request. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to stderr logging + * (STDIO servers) or OpenTelemetry. + */ +export const SetLevelRequestParamsSchema = BaseRequestParamsSchema.extend({ + /** + * 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/logging/message`. + */ + level: LoggingLevelSchema +}); +/** + * A request from the client to the server, to enable or adjust logging. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to stderr logging + * (STDIO servers) or OpenTelemetry. + */ +export const SetLevelRequestSchema = RequestSchema.extend({ + method: z.literal('logging/setLevel'), + params: SetLevelRequestParamsSchema +}); + +/** + * Parameters for a `notifications/message` notification. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to stderr logging + * (STDIO servers) or OpenTelemetry. + */ +export const LoggingMessageNotificationParamsSchema = NotificationsParamsSchema.extend({ + /** + * The severity of this log message. + */ + level: LoggingLevelSchema, + /** + * An optional name of the logger issuing this message. + */ + logger: z.string().optional(), + /** + * The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here. + */ + data: z.unknown() +}); +/** + * Notification 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. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to stderr logging + * (STDIO servers) or OpenTelemetry. + */ +export const LoggingMessageNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/message'), + params: LoggingMessageNotificationParamsSchema +}); + +/* Sampling */ +/** + * Hints to use for model selection. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ +export const ModelHintSchema = z.object({ + /** + * A hint for a model name. + */ + name: z.string().optional() +}); + +/** + * The server's preferences for model selection, requested of the client during sampling. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ +export const ModelPreferencesSchema = z.object({ + /** + * Optional hints to use for model selection. + */ + hints: z.array(ModelHintSchema).optional(), + /** + * How much to prioritize cost when selecting a model. + */ + costPriority: z.number().min(0).max(1).optional(), + /** + * How much to prioritize sampling speed (latency) when selecting a model. + */ + speedPriority: z.number().min(0).max(1).optional(), + /** + * How much to prioritize intelligence and capabilities when selecting a model. + */ + intelligencePriority: z.number().min(0).max(1).optional() +}); + +/** + * Controls tool usage behavior in sampling requests. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ +export const ToolChoiceSchema = z.object({ + /** + * Controls when tools are used: + * - `"auto"`: Model decides whether to use tools (default) + * - `"required"`: Model MUST use at least one tool before completing + * - `"none"`: Model MUST NOT use any tools + */ + mode: z.enum(['auto', 'required', 'none']).optional() +}); + +/** + * The result of a tool execution, provided by the user (server). + * Represents the outcome of invoking a tool requested via `ToolUseContent`. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ +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), + structuredContent: z.object({}).loose().optional(), + isError: z.boolean().optional(), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** + * Basic content types for sampling responses (without tool use). + * Used for backwards-compatible {@linkcode CreateMessageResult} when tools are not used. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ +export const SamplingContentSchema = z.discriminatedUnion('type', [TextContentSchema, ImageContentSchema, AudioContentSchema]); + +/** + * Content block types allowed in sampling messages. + * This includes text, image, audio, tool use requests, and tool results. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ +export const SamplingMessageContentBlockSchema = z.discriminatedUnion('type', [ + TextContentSchema, + ImageContentSchema, + AudioContentSchema, + ToolUseContentSchema, + ToolResultContentSchema +]); + +/** + * Describes a message issued to or received from an LLM API. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ +export const SamplingMessageSchema = z.object({ + role: RoleSchema, + content: z.union([SamplingMessageContentBlockSchema, z.array(SamplingMessageContentBlockSchema)]), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** + * Parameters for a `sampling/createMessage` request. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ +export const CreateMessageRequestParamsSchema = TaskAugmentedRequestParamsSchema.extend({ + messages: z.array(SamplingMessageSchema), + /** + * The server's preferences for which model to select. The client MAY modify or omit this request. + */ + modelPreferences: ModelPreferencesSchema.optional(), + /** + * An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt. + */ + systemPrompt: z.string().optional(), + /** + * A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. + * The client MAY ignore this request. + * + * Default is `"none"`. The values `"thisServer"` and `"allServers"` are deprecated (SEP-2596): servers SHOULD + * omit this field or use `"none"`, and SHOULD only use the deprecated values if the client declares + * `ClientCapabilities`.`sampling.context`. + * + * @deprecated The `"thisServer"` and `"allServers"` values are deprecated as of protocol version 2025-11-25 + * (SEP-2596) and will be removed no later than the Sampling feature itself (SEP-2577). Omit this field or use `"none"`. + */ + includeContext: z.enum(['none', 'thisServer', 'allServers']).optional(), + temperature: z.number().optional(), + /** + * The requested maximum number of tokens to sample (to prevent runaway completions). + * + * The client MAY choose to sample fewer tokens than the requested maximum. + */ + maxTokens: z.number().int(), + stopSequences: z.array(z.string()).optional(), + /** + * Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific. + */ + metadata: JSONObjectSchema.optional(), + /** + * Tools that the model may use during generation. + * The client MUST return an error if this field is provided but `ClientCapabilities`.`sampling.tools` is not declared. + */ + tools: z.array(ToolSchema).optional(), + /** + * Controls how the model uses tools. + * The client MUST return an error if this field is provided but `ClientCapabilities`.`sampling.tools` is not declared. + * Default is `{ mode: "auto" }`. + */ + toolChoice: ToolChoiceSchema.optional() +}); +/** + * 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. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ +export const CreateMessageRequestSchema = RequestSchema.extend({ + method: z.literal('sampling/createMessage'), + params: CreateMessageRequestParamsSchema +}); + +/** + * The client's response to a `sampling/create_message` request from the server. + * This is the backwards-compatible version that returns single content (no arrays). + * Used when the request does not include tools. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ +export const CreateMessageResultSchema = ResultSchema.extend({ + /** + * The name of the model that generated the message. + */ + model: z.string(), + /** + * The reason why sampling stopped, if known. + * + * Standard values: + * - `"endTurn"`: Natural end of the assistant's turn + * - `"stopSequence"`: A stop sequence was encountered + * - `"maxTokens"`: Maximum token limit was reached + * + * This field is an open string to allow for provider-specific stop reasons. + */ + stopReason: z.optional(z.enum(['endTurn', 'stopSequence', 'maxTokens']).or(z.string())), + role: RoleSchema, + /** + * Response content. Single content block (text, image, or audio). + */ + content: SamplingContentSchema +}); + +/** + * The client's response to a `sampling/create_message` request when tools were provided. + * This version supports array content for tool use flows. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to calling LLM + * provider APIs directly. + */ +export const CreateMessageResultWithToolsSchema = ResultSchema.extend({ + /** + * The name of the model that generated the message. + */ + model: z.string(), + /** + * The reason why sampling stopped, if known. + * + * Standard values: + * - `"endTurn"`: Natural end of the assistant's turn + * - `"stopSequence"`: A stop sequence was encountered + * - `"maxTokens"`: Maximum token limit was reached + * - `"toolUse"`: The model wants to use one or more tools + * + * This field is an open string to allow for provider-specific stop reasons. + */ + stopReason: z.optional(z.enum(['endTurn', 'stopSequence', 'maxTokens', 'toolUse']).or(z.string())), + role: RoleSchema, + /** + * Response content. May be a single block or array. May include `ToolUseContent` if `stopReason` is `"toolUse"`. + */ + content: z.union([SamplingMessageContentBlockSchema, z.array(SamplingMessageContentBlockSchema)]) +}); + +/* Elicitation */ +/** + * Primitive schema definition for boolean fields. + */ +export const BooleanSchemaSchema = z.object({ + type: z.literal('boolean'), + title: z.string().optional(), + description: z.string().optional(), + default: z.boolean().optional() +}); + +/** + * Primitive schema definition for string fields. + */ +export const StringSchemaSchema = z.object({ + type: z.literal('string'), + title: z.string().optional(), + description: z.string().optional(), + minLength: z.number().optional(), + maxLength: z.number().optional(), + format: z.enum(['email', 'uri', 'date', 'date-time']).optional(), + default: z.string().optional() +}); + +/** + * Primitive schema definition for number fields. + */ +export const NumberSchemaSchema = z.object({ + type: z.enum(['number', 'integer']), + title: z.string().optional(), + description: z.string().optional(), + minimum: z.number().optional(), + maximum: z.number().optional(), + default: z.number().optional() +}); + +/** + * Schema for single-selection enumeration without display titles for options. + */ +export const UntitledSingleSelectEnumSchemaSchema = z.object({ + type: z.literal('string'), + title: z.string().optional(), + description: z.string().optional(), + enum: z.array(z.string()), + default: z.string().optional() +}); + +/** + * Schema for single-selection enumeration with display titles for each option. + */ +export const TitledSingleSelectEnumSchemaSchema = z.object({ + type: z.literal('string'), + title: z.string().optional(), + description: z.string().optional(), + oneOf: z.array( + z.object({ + const: z.string(), + title: z.string() + }) + ), + default: z.string().optional() +}); + +/** + * Use {@linkcode TitledSingleSelectEnumSchema} instead. + * This interface will be removed in a future version. + */ +export const LegacyTitledEnumSchemaSchema = z.object({ + type: z.literal('string'), + title: z.string().optional(), + description: z.string().optional(), + enum: z.array(z.string()), + enumNames: z.array(z.string()).optional(), + default: z.string().optional() +}); + +// Combined single selection enumeration +export const SingleSelectEnumSchemaSchema = z.union([UntitledSingleSelectEnumSchemaSchema, TitledSingleSelectEnumSchemaSchema]); + +/** + * Schema for multiple-selection enumeration without display titles for options. + */ +export const UntitledMultiSelectEnumSchemaSchema = z.object({ + type: z.literal('array'), + title: z.string().optional(), + description: z.string().optional(), + minItems: z.number().optional(), + maxItems: z.number().optional(), + items: z.object({ + type: z.literal('string'), + enum: z.array(z.string()) + }), + default: z.array(z.string()).optional() +}); + +/** + * Schema for multiple-selection enumeration with display titles for each option. + */ +export const TitledMultiSelectEnumSchemaSchema = z.object({ + type: z.literal('array'), + title: z.string().optional(), + description: z.string().optional(), + minItems: z.number().optional(), + maxItems: z.number().optional(), + items: z.object({ + anyOf: z.array( + z.object({ + const: z.string(), + title: z.string() + }) + ) + }), + default: z.array(z.string()).optional() +}); + +/** + * Combined schema for multiple-selection enumeration + */ +export const MultiSelectEnumSchemaSchema = z.union([UntitledMultiSelectEnumSchemaSchema, TitledMultiSelectEnumSchemaSchema]); + +/** + * Primitive schema definition for enum fields. + */ +export const EnumSchemaSchema = z.union([LegacyTitledEnumSchemaSchema, SingleSelectEnumSchemaSchema, MultiSelectEnumSchemaSchema]); + +/** + * Union of all primitive schema definitions. + */ +export const PrimitiveSchemaDefinitionSchema = z.union([EnumSchemaSchema, BooleanSchemaSchema, StringSchemaSchema, NumberSchemaSchema]); + +/** + * Parameters for an `elicitation/create` request for form-based elicitation. + */ +export const ElicitRequestFormParamsSchema = TaskAugmentedRequestParamsSchema.extend({ + /** + * The elicitation mode. + * + * Optional for backward compatibility. Clients MUST treat missing `mode` as `"form"`. + */ + mode: z.literal('form').optional(), + /** + * The message to present to the user describing what information is being requested. + */ + message: z.string(), + /** + * A restricted subset of JSON Schema. + * Only top-level properties are allowed, without nesting. + */ + requestedSchema: z + .object({ + type: z.literal('object'), + properties: z.record(z.string(), PrimitiveSchemaDefinitionSchema), + required: z.array(z.string()).optional() + }) + .catchall(z.unknown()) +}); + +/** + * Parameters for an {@linkcode ElicitRequest | elicitation/create} request for URL-based elicitation. + */ +export const ElicitRequestURLParamsSchema = TaskAugmentedRequestParamsSchema.extend({ + /** + * The elicitation mode. + */ + mode: z.literal('url'), + /** + * The message to present to the user explaining why the interaction is needed. + */ + message: z.string(), + /** + * The ID of the elicitation, which must be unique within the context of the server. + * The client MUST treat this ID as an opaque value. + */ + elicitationId: z.string(), + /** + * The URL that the user should navigate to. + */ + url: z.string().url() +}); + +/** + * The parameters for a request to elicit additional information from the user via the client. + */ +export const ElicitRequestParamsSchema = z.union([ElicitRequestFormParamsSchema, ElicitRequestURLParamsSchema]); + +/** + * A request from the server to elicit user input via the client. + * The client should present the message and form fields to the user (form mode) + * or navigate to a URL (URL mode). + */ +export const ElicitRequestSchema = RequestSchema.extend({ + method: z.literal('elicitation/create'), + params: ElicitRequestParamsSchema +}); + +/** + * Parameters for a {@linkcode ElicitationCompleteNotification | notifications/elicitation/complete} notification. + * + * @category notifications/elicitation/complete + */ +export const ElicitationCompleteNotificationParamsSchema = NotificationsParamsSchema.extend({ + /** + * The ID of the elicitation that completed. + */ + elicitationId: z.string() +}); + +/** + * A notification from the server to the client, informing it of a completion of an out-of-band elicitation request. + * + * @category notifications/elicitation/complete + */ +export const ElicitationCompleteNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/elicitation/complete'), + params: ElicitationCompleteNotificationParamsSchema +}); + +/** + * The client's response to an {@linkcode ElicitRequest | elicitation/create} request from the server. + */ +export const ElicitResultSchema = ResultSchema.extend({ + /** + * The user action in response to the elicitation. + * - `"accept"`: User submitted the form/confirmed the action + * - `"decline"`: User explicitly declined the action + * - `"cancel"`: User dismissed without making an explicit choice + */ + action: z.enum(['accept', 'decline', 'cancel']), + /** + * The submitted form data, only present when action is `"accept"`. + * Contains values matching the requested schema. + * Per MCP spec, content is "typically omitted" for decline/cancel actions. + * We normalize `null` to `undefined` for leniency while maintaining type compatibility. + */ + content: z.preprocess( + val => (val === null ? undefined : val), + z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])).optional() + ) +}); + +/* Autocomplete */ +/** + * A reference to a resource or resource template definition. + */ +export const ResourceTemplateReferenceSchema = z.object({ + type: z.literal('ref/resource'), + /** + * The URI or URI template of the resource. + */ + uri: z.string() +}); + +/** + * Identifies a prompt. + */ +export const PromptReferenceSchema = z.object({ + type: z.literal('ref/prompt'), + /** + * The name of the prompt or prompt template + */ + name: z.string() +}); + +/** + * Parameters for a {@linkcode CompleteRequest | completion/complete} request. + */ +export const CompleteRequestParamsSchema = BaseRequestParamsSchema.extend({ + ref: z.union([PromptReferenceSchema, ResourceTemplateReferenceSchema]), + /** + * The argument's information + */ + argument: z.object({ + /** + * The name of the argument + */ + name: z.string(), + /** + * The value of the argument to use for completion matching. + */ + value: z.string() + }), + context: z + .object({ + /** + * Previously-resolved variables in a URI template or prompt. + */ + arguments: z.record(z.string(), z.string()).optional() + }) + .optional() +}); +/** + * A request from the client to the server, to ask for completion options. + */ +export const CompleteRequestSchema = RequestSchema.extend({ + method: z.literal('completion/complete'), + params: CompleteRequestParamsSchema +}); + +/** + * The server's response to a {@linkcode CompleteRequest | completion/complete} request + */ +export const CompleteResultSchema = ResultSchema.extend({ + completion: z.looseObject({ + /** + * An array of completion values. Must not exceed 100 items. + */ + values: z.array(z.string()).max(100), + /** + * The total number of completion options available. This can exceed the number of values actually sent in the response. + */ + total: z.optional(z.number().int()), + /** + * Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown. + */ + hasMore: z.optional(z.boolean()) + }) +}); + +/* Roots */ +/** + * Represents a root directory or file that the server can operate on. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to passing paths via + * tool parameters, resource URIs, or configuration. + */ +export const RootSchema = z.object({ + /** + * The URI identifying the root. This *must* start with `file://` for now. + */ + uri: z.string().startsWith('file://'), + /** + * An optional name for the root. + */ + name: z.string().optional(), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** + * Sent from the server to request a list of root URIs from the client. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to passing paths via + * tool parameters, resource URIs, or configuration. + */ +export const ListRootsRequestSchema = RequestSchema.extend({ + method: z.literal('roots/list'), + params: BaseRequestParamsSchema.optional() +}); + +/** + * The client's response to a `roots/list` request from the server. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to passing paths via + * tool parameters, resource URIs, or configuration. + */ +export const ListRootsResultSchema = ResultSchema.extend({ + roots: z.array(RootSchema) +}); + +/** + * A notification from the client to the server, informing it that the list of roots has changed. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. Migrate to passing paths via + * tool parameters, resource URIs, or configuration. + */ +export const RootsListChangedNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/roots/list_changed'), + params: NotificationsParamsSchema.optional() +}); + +/* ─────────────────────────────────────────────────────────────────────────── + * Tasks (2025-11-25 wire vocabulary; restored types-only by #2248 for interop + * with task-capable 2025 peers — parsed ONLY through this era's registry). + * ─────────────────────────────────────────────────────────────────────────── */ + +/** + * 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']); + +/** + * 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 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 +]); 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..ee36905fe6 --- /dev/null +++ b/packages/core/src/wire/rev2025-11-25/wireTypes.ts @@ -0,0 +1,175 @@ +/** + * 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`. + * - SEP-2106: `CallToolResult.structuredContent` / the `tool_result` + * sampling-content arm's `structuredContent` / `Tool.outputSchema`: 2025 + * wire object-only; neutral `unknown` / open JSON Schema document. The + * 2025 wire-exact shape is inferred directly from the FROZEN copy in + * `./schemas.ts` (Wire2025SamplingMessage). + * - `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 * as z4 from 'zod/v4'; + +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'; +import type { SamplingMessageSchema as Frozen2025SamplingMessageSchema } from './schemas.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 }; + +/** SEP-2106 adjudication: inferred from the FROZEN 2025 schema (object-only `structuredContent`). */ +export type Wire2025SamplingMessage = z4.infer; + +export type Wire2025CreateMessageRequestParams = OmitKnown & { + metadata?: object; + tools?: Wire2025Tool[]; + messages: Wire2025SamplingMessage[]; +}; + +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..6b6713d7e0 --- /dev/null +++ b/packages/core/src/wire/rev2026-07-28/codec.ts @@ -0,0 +1,307 @@ +/** + * 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 { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + LOG_LEVEL_META_KEY, + PROTOCOL_VERSION_META_KEY +} from '../../types/constants.js'; +import type { CallToolResult, Result } from '../../types/types.js'; +import type { DecodedResult, EnvelopeIssue, LiftedWireMaterial, OutboundEnvelopeMaterial, ValidateOutcome, WireCodec } from '../codec.js'; +import { appendTextFallbackForNonObject } from '../textFallback.js'; +import { fillCacheFields, stampResultType } from './encodeContract.js'; +import { getInputRequestSchema2026, getInputResponseSchema2026 } from './inputRequired.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); +} + +/** Tri-state wrap of an optional Zod schema lookup (the function-only contract). */ +function triState(schema: z.ZodType | undefined, raw: unknown): ValidateOutcome { + if (schema === undefined) return { ok: false, reason: 'not-in-era' }; + const parsed = schema.safeParse(raw); + return parsed.success ? { ok: true, value: parsed.data } : { ok: false, reason: 'invalid', message: String(parsed.error) }; +} + +const NOT_IN_ERA: ValidateOutcome = { ok: false, reason: 'not-in-era' }; + +/** The reserved `_meta` keys an envelope must carry on this era (in reporting order). */ +const REQUIRED_ENVELOPE_KEYS: readonly string[] = [PROTOCOL_VERSION_META_KEY, CLIENT_INFO_META_KEY, CLIENT_CAPABILITIES_META_KEY]; + +/** 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 & { + /** + * @deprecated Off-interface in-band registry probe retained for the + * existing inputRequiredFunnel pin. Use {@link WireCodec.validateInputRequest} + * — its `not-in-era` outcome is the membership signal. + */ + inputRequestSchema(method: string): unknown; +} = { + era: '2026-07-28', + + hasRequestMethod: hasRequestMethod2026, + hasNotificationMethod: hasNotificationMethod2026, + hasInputRequestMethod: (method: string): boolean => getInputRequestSchema2026(method) !== undefined, + + // ── Function-only validation surface ── + validateRequest: (method: string, raw: unknown) => triState(getRequestSchema2026(method), raw), + validateResult: (method: string, raw: unknown) => triState(getResultSchema2026(method), raw), + validateNotification: (method: string, raw: unknown) => triState(getNotificationSchema2026(method), raw), + // In-band multi-round-trip vocabulary: the demoted elicitation/sampling/ + // roots shapes carried inside `input_required` results (NOT wire request + // methods on this era — registry membership is deliberately not granted). + validateInputRequest: (method: string, raw: unknown) => triState(getInputRequestSchema2026(method), raw), + validateInputResponse: (method: string, raw: unknown) => triState(getInputResponseSchema2026(method), raw), + + // Sampling is in-band on this era — callers fall through to + // `validateInputResponse('sampling/createMessage', …)`. + samplingResultVariant: (): ValidateOutcome => NOT_IN_ERA, + + outboundEnvelope(material: OutboundEnvelopeMaterial): Readonly> { + return { + [PROTOCOL_VERSION_META_KEY]: material.protocolVersion, + [CLIENT_INFO_META_KEY]: material.clientInfo, + [CLIENT_CAPABILITIES_META_KEY]: material.clientCapabilities, + ...(material.logLevel !== undefined && { [LOG_LEVEL_META_KEY]: material.logLevel }) + }; + }, + + validateEnvelopeMeta(meta: Readonly>): 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; + }, + + // SEP-2106 result-side projection: no `{result:…}` wrap on the modern era + // (the wire shape carries the natural `structuredContent` directly), but + // the era-agnostic §4.3 TextContent auto-append still applies. + projectCallToolResult: (result: CallToolResult): CallToolResult => appendTextFallbackForNonObject(result), + + // Retained off-interface for the inputRequiredFunnel registry-membership + // pin (asserts the in-band schema set without exercising parse): the + // function-only WireCodec contract carries no schema-returning members, + // so this lives only on the concrete object's widened type. + inputRequestSchema: getInputRequestSchema2026, + + 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 rawInputRequests = raw['inputRequests']; + const inputRequests = isPlainObject(rawInputRequests) ? rawInputRequests : {}; + const requestState = raw['requestState']; + if (Object.keys(inputRequests).length === 0 && typeof requestState !== 'string') { + // At-least-one rule, client side: with neither inputRequests + // nor requestState there is nothing to fulfil and nothing to + // echo — retrying would only resend the original params until + // the round cap is exhausted, so fail fast instead. + return { + kind: 'invalid', + error: new SdkError( + SdkErrorCode.InvalidResult, + `Invalid result for ${method}: input_required carries neither inputRequests nor requestState ` + + `(every input_required result must include at least one of the two)`, + { method, violation: 'input-required-missing-both' } + ) + }; + } + return { + kind: 'input_required', + inputRequests, + ...(typeof requestState === 'string' && { 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))); + }, + + // The −32002 resource-not-found domain code maps to −32602 Invalid Params + // on the wire (the 2026-07-28 spec MUST for resources/read misses). + encodeErrorCode: (code: number): number => (code === -32_002 ? -32_602 : code), + + 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..de8f09a951 --- /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` is NOT in this set: it never emits + * a JSON-RPC result — termination is stream close (HTTP) or + * `notifications/cancelled` (stdio) per the spec. + */ +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/inputRequired.ts b/packages/core/src/wire/rev2026-07-28/inputRequired.ts new file mode 100644 index 0000000000..365a178d73 --- /dev/null +++ b/packages/core/src/wire/rev2026-07-28/inputRequired.ts @@ -0,0 +1,83 @@ +/** + * In-band input-request vocabulary of the 2026-07-28 revision (SEP-2322 + * multi round-trip requests), dispatch view. + * + * The three former server→client wire requests (`elicitation/create`, + * `sampling/createMessage`, `roots/list`) are NOT wire request methods on + * this revision — they are demoted to de-JSON-RPC'd payloads embedded in an + * `input_required` result. The multi-round-trip driver dispatches those + * embedded payloads to the client's registered handlers through the normal + * handler machinery, and these are the schemas that dispatch parses them + * with: lenient where the anchor's wire-true artifacts are strict (an + * embedded request never carries the per-request `_meta` envelope), exact + * where the vocabulary forks (the sampling shapes compose the forked + * SamplingMessage/Tool payloads). + * + * Registry membership is intentionally NOT granted here — these methods stay + * absent from the 2026-era request registry (a peer sending one as a wire + * request still gets −32601 by absence). Only the codec's + * `inputRequestSchema`/`inputResponseSchema` accessors expose them. + */ +import * as z from 'zod/v4'; + +import type { RequestMethod, RequestTypeMap, ResultTypeMap } from '../../types/types.js'; +import { + CreateMessageRequestParamsSchema, + CreateMessageResultSchema, + ElicitRequestParamsSchema, + ElicitResultSchema, + ListRootsResultSchema +} from './schemas.js'; + +/** The embedded input-request methods of the 2026-07-28 revision. */ +export const INPUT_REQUEST_METHODS_2026 = ['elicitation/create', 'sampling/createMessage', 'roots/list'] as const; + +export type InputRequestMethod2026 = (typeof INPUT_REQUEST_METHODS_2026)[number]; + +/** Dispatch-time (lenient) embedded request schemas, keyed by method. */ +const inputRequestSchemas2026: Record = { + 'elicitation/create': z.object({ + method: z.literal('elicitation/create'), + params: ElicitRequestParamsSchema + }), + 'sampling/createMessage': z.object({ + method: z.literal('sampling/createMessage'), + params: CreateMessageRequestParamsSchema + }), + 'roots/list': z.object({ + method: z.literal('roots/list'), + params: z.looseObject({}).optional() + }) +}; + +/** Embedded (bare) response schemas, keyed by the request method they answer. */ +const inputResponseSchemas2026: Record = { + 'elicitation/create': ElicitResultSchema, + 'sampling/createMessage': CreateMessageResultSchema, + 'roots/list': ListRootsResultSchema +}; + +export function isInputRequestMethod2026(method: string): method is InputRequestMethod2026 { + return (INPUT_REQUEST_METHODS_2026 as readonly string[]).includes(method); +} + +/** + * Gets the dispatch (lenient) schema for an embedded input request, or + * `undefined` for methods that are not in-band vocabulary on this era. + * The typed overload mirrors `WireCodec.inputRequestSchema`. + */ +export function getInputRequestSchema2026(method: M): z.ZodType | undefined; +export function getInputRequestSchema2026(method: string): z.ZodType | undefined; +export function getInputRequestSchema2026(method: string): z.ZodType | undefined { + return isInputRequestMethod2026(method) ? inputRequestSchemas2026[method] : undefined; +} + +/** + * Gets the bare embedded-response schema answering an embedded input request, + * or `undefined` for methods that are not in-band vocabulary on this era. + */ +export function getInputResponseSchema2026(method: M): z.ZodType | undefined; +export function getInputResponseSchema2026(method: string): z.ZodType | undefined; +export function getInputResponseSchema2026(method: string): z.ZodType | undefined { + return isInputRequestMethod2026(method) ? inputResponseSchemas2026[method] : undefined; +} 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..969c719bbd --- /dev/null +++ b/packages/core/src/wire/rev2026-07-28/registry.ts @@ -0,0 +1,88 @@ +/** + * 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`, `notifications/elicitation/complete` + * (removed from the draft schema; 2025-11-25-only vocabulary), 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, present here as registry shells. + * Dispatch never reaches a registered handler — the serving entries + * (`createMcpHandler`, `serveStdio`) recognize listen at the entry layer + * and own ack/filter/stamp/teardown themselves; on the client side + * `Client.listen()` sends directly on the transport (string-typed + * request id, transport-level demux) rather than via `request()`. + */ +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..240a75d655 --- /dev/null +++ b/packages/core/src/wire/rev2026-07-28/schemas.ts @@ -0,0 +1,1249 @@ +/** + * 2026-era wire schemas (protocol revision 2026-07-28). + * + * Fully self-contained — no runtime imports from types/schemas.ts. The + * neutral types/schemas.ts layer is the public-API superset and is free to + * evolve; this file is the 2026 wire-parse contract and is BEHAVIOR-FROZEN + * against the 2026-07-28 anchor. Every era-shared building block (content + * blocks, resources, prompts, capabilities, notifications, …) that the wire + * shapes compose is a frozen LOCAL copy — verbatim from the neutral layer at + * the point this revision was sealed, dependencies first. The only cross-layer + * dependency is `import type { JSONObject, JSONValue }` from the neutral types + * barrel — pure structural type aliases with no parse behavior. + * + * 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 type { JSONObject, JSONValue } from '../../types/types.js'; + +/* ════════════════════════════════════════════════════════════════════════════ + * Frozen neutral-layer building blocks + * + * Everything from this point until the next ═-banner is a verbatim frozen + * copy of a schema that, at the time this revision was sealed, lived in the + * neutral types/schemas.ts. They are copied dependencies-first so no forward + * references exist. They are NOT re-derived from the public layer at runtime — + * a widening or tightening landed on types/schemas.ts has no effect here until + * a deliberate per-revision re-freeze. + * ════════════════════════════════════════════════════════════════════════════ */ + +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)]) +); +export const JSONObjectSchema: z.ZodType = z.record(z.string(), JSONValueSchema); + +/** + * A progress token, used to associate progress notifications with the original request. + */ +export const ProgressTokenSchema = z.union([z.string(), z.number().int()]); + +/** + * An opaque token used to represent a cursor for pagination. + */ +export const CursorSchema = z.string(); + +/** + * A uniquely identifying ID for a request in JSON-RPC. + */ +export const RequestIdSchema = z.union([z.string(), z.number().int()]); + +/** + * The sender or recipient of messages and data in a conversation. + */ +export const RoleSchema = z.enum(['user', 'assistant']); + +/** + * The severity of a log message. + */ +export const LoggingLevelSchema = z.enum(['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency']); + +/** + * A Zod schema for validating Base64 strings that is more performant and + * robust for very large inputs than the default regex-based check. It avoids + * stack overflows by using the native `atob` function for validation. + */ +const Base64Schema = z.string().refine( + val => { + try { + atob(val); + return true; + } catch { + return false; + } + }, + { message: 'Invalid Base64 string' } +); + +/* ─── Request/notification meta and base params ─── */ + +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ +export const TaskMetadataSchema = z.object({ + ttl: z.number().optional() +}); + +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ +export const RelatedTaskMetadataSchema = z.object({ + taskId: z.string() +}); + +export const RequestMetaSchema = 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(), + /** + * If specified, this request is related to the provided task. + */ + 'io.modelcontextprotocol/related-task': RelatedTaskMetadataSchema.optional() +}); + +export const BaseRequestParamsSchema = z.object({ + _meta: RequestMetaSchema.optional() +}); + +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ +export const TaskAugmentedRequestParamsSchema = BaseRequestParamsSchema.extend({ + task: TaskMetadataSchema.optional() +}); + +export const NotificationsParamsSchema = z.object({ + _meta: RequestMetaSchema.optional() +}); + +export const NotificationSchema = z.object({ + method: z.string(), + params: NotificationsParamsSchema.loose().optional() +}); + +/* ─── Icons / base metadata / implementation ─── */ + +export const IconSchema = z.object({ + src: z.string(), + mimeType: z.string().optional(), + sizes: z.array(z.string()).optional(), + theme: z.enum(['light', 'dark']).optional() +}); + +export const IconsSchema = z.object({ + icons: z.array(IconSchema).optional() +}); + +export const BaseMetadataSchema = z.object({ + name: z.string(), + title: z.string().optional() +}); + +export const ImplementationSchema = BaseMetadataSchema.extend({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + version: z.string(), + websiteUrl: z.string().optional(), + description: z.string().optional() +}); + +/* ─── Capability schemas ─── */ + +const FormElicitationCapabilitySchema = z.intersection( + z.object({ + applyDefaults: z.boolean().optional() + }), + JSONObjectSchema +); + +const ElicitationCapabilitySchema = z.preprocess( + value => { + if (value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record).length === 0) { + return { form: {} }; + } + return value; + }, + z.intersection( + z.object({ + form: FormElicitationCapabilitySchema.optional(), + url: JSONObjectSchema.optional() + }), + JSONObjectSchema.optional() + ) +); + +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ +export const ClientTasksCapabilitySchema = z.looseObject({ + list: JSONObjectSchema.optional(), + cancel: JSONObjectSchema.optional(), + requests: z + .looseObject({ + sampling: z + .looseObject({ + createMessage: JSONObjectSchema.optional() + }) + .optional(), + elicitation: z + .looseObject({ + create: JSONObjectSchema.optional() + }) + .optional() + }) + .optional() +}); + +/** @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only. */ +export const ServerTasksCapabilitySchema = z.looseObject({ + list: JSONObjectSchema.optional(), + cancel: JSONObjectSchema.optional(), + requests: z + .looseObject({ + tools: z + .looseObject({ + call: JSONObjectSchema.optional() + }) + .optional() + }) + .optional() +}); + +export const ClientCapabilitiesSchema = z.object({ + experimental: z.record(z.string(), JSONObjectSchema).optional(), + sampling: z + .object({ + context: JSONObjectSchema.optional(), + tools: JSONObjectSchema.optional() + }) + .optional(), + elicitation: ElicitationCapabilitySchema.optional(), + roots: z + .object({ + listChanged: z.boolean().optional() + }) + .optional(), + tasks: ClientTasksCapabilitySchema.optional(), + extensions: z.record(z.string(), JSONObjectSchema).optional() +}); + +export const ServerCapabilitiesSchema = z.object({ + experimental: z.record(z.string(), JSONObjectSchema).optional(), + logging: JSONObjectSchema.optional(), + completions: JSONObjectSchema.optional(), + prompts: z + .object({ + listChanged: z.boolean().optional() + }) + .optional(), + resources: z + .object({ + subscribe: z.boolean().optional(), + listChanged: z.boolean().optional() + }) + .optional(), + tools: z + .object({ + listChanged: z.boolean().optional() + }) + .optional(), + tasks: ServerTasksCapabilitySchema.optional(), + extensions: z.record(z.string(), JSONObjectSchema).optional() +}); + +/* ─── Progress / logging notifications ─── */ + +export const ProgressSchema = z.object({ + progress: z.number(), + total: z.optional(z.number()), + message: z.optional(z.string()) +}); + +export const ProgressNotificationParamsSchema = z.object({ + ...NotificationsParamsSchema.shape, + ...ProgressSchema.shape, + progressToken: ProgressTokenSchema +}); + +export const ProgressNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/progress'), + params: ProgressNotificationParamsSchema +}); + +export const LoggingMessageNotificationParamsSchema = NotificationsParamsSchema.extend({ + level: LoggingLevelSchema, + logger: z.string().optional(), + data: z.unknown() +}); + +export const LoggingMessageNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/message'), + params: LoggingMessageNotificationParamsSchema +}); + +/* ─── Resource contents / annotations ─── */ + +export const ResourceContentsSchema = z.object({ + uri: z.string(), + mimeType: z.optional(z.string()), + _meta: z.record(z.string(), z.unknown()).optional() +}); + +export const TextResourceContentsSchema = ResourceContentsSchema.extend({ + text: z.string() +}); + +export const BlobResourceContentsSchema = ResourceContentsSchema.extend({ + blob: Base64Schema +}); + +export const AnnotationsSchema = z.object({ + audience: z.array(RoleSchema).optional(), + priority: z.number().min(0).max(1).optional(), + lastModified: z.iso.datetime({ offset: true }).optional() +}); + +/* ─── Resources / templates / list-changed notifications ─── */ + +export const ResourceSchema = z.object({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + uri: z.string(), + description: z.optional(z.string()), + mimeType: z.optional(z.string()), + size: z.optional(z.number()), + annotations: AnnotationsSchema.optional(), + _meta: z.optional(z.looseObject({})) +}); + +export const ResourceTemplateSchema = z.object({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + uriTemplate: z.string(), + description: z.optional(z.string()), + mimeType: z.optional(z.string()), + annotations: AnnotationsSchema.optional(), + _meta: z.optional(z.looseObject({})) +}); + +export const ResourceListChangedNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/resources/list_changed'), + params: NotificationsParamsSchema.optional() +}); + +export const ResourceUpdatedNotificationParamsSchema = NotificationsParamsSchema.extend({ + uri: z.string() +}); + +export const ResourceUpdatedNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/resources/updated'), + params: ResourceUpdatedNotificationParamsSchema +}); + +/* ─── Prompts / content blocks ─── */ + +export const PromptArgumentSchema = z.object({ + name: z.string(), + description: z.optional(z.string()), + required: z.optional(z.boolean()) +}); + +export const PromptSchema = z.object({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + description: z.optional(z.string()), + arguments: z.optional(z.array(PromptArgumentSchema)), + _meta: z.optional(z.looseObject({})) +}); + +export const PromptListChangedNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/prompts/list_changed'), + params: NotificationsParamsSchema.optional() +}); + +export const TextContentSchema = z.object({ + type: z.literal('text'), + text: z.string(), + annotations: AnnotationsSchema.optional(), + _meta: z.record(z.string(), z.unknown()).optional() +}); + +export const ImageContentSchema = z.object({ + type: z.literal('image'), + data: Base64Schema, + mimeType: z.string(), + annotations: AnnotationsSchema.optional(), + _meta: z.record(z.string(), z.unknown()).optional() +}); + +export const AudioContentSchema = z.object({ + type: z.literal('audio'), + data: Base64Schema, + mimeType: z.string(), + annotations: AnnotationsSchema.optional(), + _meta: z.record(z.string(), z.unknown()).optional() +}); + +export const ToolUseContentSchema = z.object({ + type: z.literal('tool_use'), + name: z.string(), + id: z.string(), + input: z.record(z.string(), z.unknown()), + _meta: z.record(z.string(), z.unknown()).optional() +}); + +export const EmbeddedResourceSchema = z.object({ + type: z.literal('resource'), + resource: z.union([TextResourceContentsSchema, BlobResourceContentsSchema]), + annotations: AnnotationsSchema.optional(), + _meta: z.record(z.string(), z.unknown()).optional() +}); + +export const ResourceLinkSchema = ResourceSchema.extend({ + type: z.literal('resource_link') +}); + +export const ContentBlockSchema = z.union([ + TextContentSchema, + ImageContentSchema, + AudioContentSchema, + ResourceLinkSchema, + EmbeddedResourceSchema +]); + +export const PromptMessageSchema = z.object({ + role: RoleSchema, + content: ContentBlockSchema +}); + +/* ─── Tool annotations / tool list-changed / sampling primitives ─── */ + +export const ToolAnnotationsSchema = z.object({ + title: z.string().optional(), + readOnlyHint: z.boolean().optional(), + destructiveHint: z.boolean().optional(), + idempotentHint: z.boolean().optional(), + openWorldHint: z.boolean().optional() +}); + +export const ToolListChangedNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/tools/list_changed'), + params: NotificationsParamsSchema.optional() +}); + +export const ModelHintSchema = z.object({ + name: z.string().optional() +}); + +export const ModelPreferencesSchema = z.object({ + hints: z.array(ModelHintSchema).optional(), + costPriority: z.number().min(0).max(1).optional(), + speedPriority: z.number().min(0).max(1).optional(), + intelligencePriority: z.number().min(0).max(1).optional() +}); + +export const ToolChoiceSchema = z.object({ + mode: z.enum(['auto', 'required', 'none']).optional() +}); + +/* ─── Elicitation primitive-schema vocabulary ─── */ + +export const BooleanSchemaSchema = z.object({ + type: z.literal('boolean'), + title: z.string().optional(), + description: z.string().optional(), + default: z.boolean().optional() +}); + +export const StringSchemaSchema = z.object({ + type: z.literal('string'), + title: z.string().optional(), + description: z.string().optional(), + minLength: z.number().optional(), + maxLength: z.number().optional(), + format: z.enum(['email', 'uri', 'date', 'date-time']).optional(), + default: z.string().optional() +}); + +export const NumberSchemaSchema = z.object({ + type: z.enum(['number', 'integer']), + title: z.string().optional(), + description: z.string().optional(), + minimum: z.number().optional(), + maximum: z.number().optional(), + default: z.number().optional() +}); + +export const UntitledSingleSelectEnumSchemaSchema = z.object({ + type: z.literal('string'), + title: z.string().optional(), + description: z.string().optional(), + enum: z.array(z.string()), + default: z.string().optional() +}); + +export const TitledSingleSelectEnumSchemaSchema = z.object({ + type: z.literal('string'), + title: z.string().optional(), + description: z.string().optional(), + oneOf: z.array( + z.object({ + const: z.string(), + title: z.string() + }) + ), + default: z.string().optional() +}); + +export const LegacyTitledEnumSchemaSchema = z.object({ + type: z.literal('string'), + title: z.string().optional(), + description: z.string().optional(), + enum: z.array(z.string()), + enumNames: z.array(z.string()).optional(), + default: z.string().optional() +}); + +export const SingleSelectEnumSchemaSchema = z.union([UntitledSingleSelectEnumSchemaSchema, TitledSingleSelectEnumSchemaSchema]); + +export const UntitledMultiSelectEnumSchemaSchema = z.object({ + type: z.literal('array'), + title: z.string().optional(), + description: z.string().optional(), + minItems: z.number().optional(), + maxItems: z.number().optional(), + items: z.object({ + type: z.literal('string'), + enum: z.array(z.string()) + }), + default: z.array(z.string()).optional() +}); + +export const TitledMultiSelectEnumSchemaSchema = z.object({ + type: z.literal('array'), + title: z.string().optional(), + description: z.string().optional(), + minItems: z.number().optional(), + maxItems: z.number().optional(), + items: z.object({ + anyOf: z.array( + z.object({ + const: z.string(), + title: z.string() + }) + ) + }), + default: z.array(z.string()).optional() +}); + +export const MultiSelectEnumSchemaSchema = z.union([UntitledMultiSelectEnumSchemaSchema, TitledMultiSelectEnumSchemaSchema]); + +export const EnumSchemaSchema = z.union([LegacyTitledEnumSchemaSchema, SingleSelectEnumSchemaSchema, MultiSelectEnumSchemaSchema]); + +export const PrimitiveSchemaDefinitionSchema = z.union([EnumSchemaSchema, BooleanSchemaSchema, StringSchemaSchema, NumberSchemaSchema]); + +export const ElicitRequestFormParamsSchema = TaskAugmentedRequestParamsSchema.extend({ + mode: z.literal('form').optional(), + message: z.string(), + requestedSchema: z + .object({ + type: z.literal('object'), + properties: z.record(z.string(), PrimitiveSchemaDefinitionSchema), + required: z.array(z.string()).optional() + }) + .catchall(z.unknown()) +}); + +/* ─── Completion references / roots ─── */ + +export const ResourceTemplateReferenceSchema = z.object({ + type: z.literal('ref/resource'), + uri: z.string() +}); + +export const PromptReferenceSchema = z.object({ + type: z.literal('ref/prompt'), + name: z.string() +}); + +export const RootSchema = z.object({ + uri: z.string().startsWith('file://'), + name: z.string().optional(), + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/* ════════════════════════════════════════════════════════════════════════════ + * End of frozen neutral-layer building blocks. Everything below is the + * 2026-07-28 wire-specific vocabulary (envelope, forks, results, requests, + * notifications) composed against the frozen copies above. + * ════════════════════════════════════════════════════════════════════════════ */ + +/* 2026-era capability forks (defined ahead of the envelope, which composes + * the client fork). The frozen 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). + * + * Both forks list their members EXPLICITLY (composing the frozen 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 frozen 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 +}); +const sharedServerCapabilityShape = ServerCapabilitiesSchema.shape; +export const ServerCapabilities2026Schema = z.object({ + experimental: sharedServerCapabilityShape.experimental, + logging: sharedServerCapabilityShape.logging, + completions: sharedServerCapabilityShape.completions, + prompts: sharedServerCapabilityShape.prompts, + resources: sharedServerCapabilityShape.resources, + tools: sharedServerCapabilityShape.tools, + extensions: sharedServerCapabilityShape.extensions +}); + +/* 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 a sender obligation (spec.types.2026-07-28 + * Result.resultType: "Servers implementing this protocol version MUST + * include this field") and a receiver default (schema.ts:208 — clients MUST + * treat absent `resultType` as 'complete'). 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. + * Sender-side requiredness is enforced by construction (`encodeResult` + * stamps it), so the parse side carries the receiver default. + * ------------------------------------------------------------------------ */ + +/** 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, + /** Sender MUST set; receiver defaults absent → 'complete' (spec receiver leniency). */ + resultType: ResultTypeSchema.default('complete'), + ...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({ + // Receiver-side leniency per caching.mdx:56-58 — the probe classifier must + // accept a DiscoverResult that omits OR malforms the cache hints (spec: + // "if ttlMs is negative, clients SHOULD ignore it and treat it as 0"). + // `.catch()` returns the fallback for both absence and parse failure; + // sender obligation is enforced by `encodeResult`, not by parse. + // eslint-disable-next-line unicorn/prefer-top-level-await -- Zod `.catch()`, not a Promise + ttlMs: z.number().int().min(0).catch(0), + // eslint-disable-next-line unicorn/prefer-top-level-await -- Zod `.catch()`, not a Promise + cacheScope: z.enum(['public', 'private']).catch('private'), + supportedVersions: z.array(z.string()), + capabilities: ServerCapabilities2026Schema, + serverInfo: ImplementationSchema, + instructions: z.string().optional() +}); + +/* ------------------------------------------------------------------------ * + * Multi round-trip requests (SEP-2322). The in-band vocabulary of this + * revision: server→client interactions are carried as de-JSON-RPC'd embedded + * requests inside an `input_required` result, fulfilled by the client, and + * echoed back as embedded responses on the retry. The shapes below are + * anchor-exact wire artifacts (corpus + parity); the lenient dispatch-time + * schemas the multi-round-trip driver parses embedded requests with live in + * `inputRequired.ts`. + * + * The sampling shapes fork here (they compose the forked SamplingMessage / + * Tool payloads); the URL-mode elicitation params fork here (the draft + * removed `elicitationId`; the shared schema keeps it because it is required + * on the frozen 2025-11-25 revision); form-mode elicitation params are + * revision-identical and are composed by reference from the shared schema. + * ------------------------------------------------------------------------ */ + +/** 2026-era CreateMessageRequestParams (anchor-exact: forked SamplingMessage/Tool, no task augmentation). */ +export const CreateMessageRequestParamsSchema = z.object({ + messages: z.array(SamplingMessageSchema), + modelPreferences: ModelPreferencesSchema.optional(), + systemPrompt: z.string().optional(), + includeContext: z.enum(['none', 'thisServer', 'allServers']).optional(), + temperature: z.number().optional(), + maxTokens: z.number().int(), + stopSequences: z.array(z.string()).optional(), + metadata: JSONObjectSchema.optional(), + tools: z.array(ToolSchema).optional(), + toolChoice: ToolChoiceSchema.optional() +}); + +/** 2026-era embedded sampling request (de-JSON-RPC'd). */ +export const CreateMessageRequestSchema = z.object({ + method: z.literal('sampling/createMessage'), + params: CreateMessageRequestParamsSchema +}); + +/** + * 2026-era embedded roots listing request (de-JSON-RPC'd). Embedded input + * requests do NOT carry the per-request `_meta` envelope on this revision — + * the anchor declares a bare optional `_meta` on `params`. + */ +export const ListRootsRequestSchema = z.object({ + method: z.literal('roots/list'), + params: z.object({ _meta: z.record(z.string(), z.unknown()).optional() }).optional() +}); + +/** 2026-era embedded sampling response (anchor-exact: extends the forked SamplingMessage). */ +export const CreateMessageResultSchema = z.object({ + ...SamplingMessageSchema.shape, + model: z.string(), + stopReason: z.string().optional() +}); + +/** 2026-era embedded roots listing response (anchor-exact: bare `roots` array). */ +export const ListRootsResultSchema = z.object({ + roots: z.array(RootSchema) +}); + +/** 2026-era embedded elicitation response (anchor-exact: bare result, restricted content value types). */ +export const ElicitResultSchema = z.object({ + action: z.enum(['accept', 'decline', 'cancel']), + content: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])).optional() +}); + +/** + * 2026-era URL-mode elicitation params (anchor-exact fork): the draft removed + * `elicitationId` (and the `notifications/elicitation/complete` channel it + * keyed) — the shared schema keeps the field because it is required on the + * frozen 2025-11-25 revision. + */ +export const ElicitRequestURLParamsSchema = z.object({ + mode: z.literal('url'), + message: z.string(), + url: z.string().url() +}); + +/** 2026-era elicitation params (form mode is revision-identical; URL mode is the fork above). */ +export const ElicitRequestParamsSchema = z.union([ElicitRequestFormParamsSchema, ElicitRequestURLParamsSchema]); + +/** 2026-era embedded elicitation request (de-JSON-RPC'd; see the URL-mode fork above). */ +export const ElicitRequestSchema = z.object({ + method: z.literal('elicitation/create'), + params: ElicitRequestParamsSchema +}); + +/** A single embedded input request (one of the three demoted server→client requests). */ +export const InputRequestSchema = z.union([CreateMessageRequestSchema, ListRootsRequestSchema, ElicitRequestSchema]); + +/** A single embedded input response — the BARE result union (never a `{method, result}` wrapper). */ +export const InputResponseSchema = z.union([CreateMessageResultSchema, ListRootsResultSchema, ElicitResultSchema]); + +/** Map of embedded input requests, keyed by server-assigned identifiers. */ +export const InputRequestsSchema = z.record(z.string(), InputRequestSchema); + +/** Map of embedded input responses, keyed by the corresponding request identifiers. */ +export const InputResponsesSchema = z.record(z.string(), InputResponseSchema); + +/** + * The wire InputRequiredResult: `resultType: 'input_required'` plus at least + * one of `inputRequests` / `requestState` (the at-least-one rule is enforced + * at the server seam, not by this parse shape). + */ +export const InputRequiredResultSchema = wireResult({ + inputRequests: InputRequestsSchema.optional(), + requestState: z.string().optional() +}); + +/** The retry-channel members carried by client-initiated requests on this revision. */ +const retryParamsShape = { + inputResponses: InputResponsesSchema.optional(), + requestState: z.string().optional() +}; + +/** Anchor InputResponseRequestParams: the retry channel on top of the required request `_meta` envelope. */ +export const InputResponseRequestParamsSchema = z.object({ + _meta: RequestMetaEnvelopeSchema, + ...retryParamsShape +}); + +/* ------------------------------------------------------------------------ * + * 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(), + // Multi-round-trip retry channel (the wire-true view models it; dispatch + // never sees it — the protocol layer lifts it before any handler runs). + ...retryParamsShape +}; +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(), + ...retryParamsShape +}); +export const ListResourcesRequestSchema = wireRequest('resources/list', paginatedParamsShape); +export const ListResourceTemplatesRequestSchema = wireRequest('resources/templates/list', paginatedParamsShape); +export const ReadResourceRequestSchema = wireRequest('resources/read', { uri: z.string(), ...retryParamsShape }); +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', {}); + +/** Anchor SubscriptionFilter (2026-only). */ +export const SubscriptionFilterSchema = z.object({ + toolsListChanged: z.boolean().optional(), + promptsListChanged: z.boolean().optional(), + resourcesListChanged: z.boolean().optional(), + resourceSubscriptions: z.array(z.string()).optional() +}); +const subscriptionsListenParamsShape = { notifications: SubscriptionFilterSchema }; +export const SubscriptionsListenRequestSchema = wireRequest('subscriptions/listen', subscriptionsListenParamsShape); + +/** Anchor SubscriptionsListenResultMeta — required subscriptionId stamp on the graceful-close result. */ +export const SubscriptionsListenResultMetaSchema = z.looseObject({ + 'io.modelcontextprotocol/subscriptionId': RequestIdSchema +}); + +/** + * Anchor SubscriptionsListenResult (2026-only). The empty `subscriptions/listen` + * response signalling that the subscription has ended gracefully (server + * shutdown). An abrupt transport close carries no response — the client treats + * stream-close-without-result as a disconnect. + */ +export const SubscriptionsListenResultSchema = z.looseObject({ + /** Required `_meta` (the subscriptionId stamp); the result body is otherwise empty. */ + _meta: SubscriptionsListenResultMetaSchema, + resultType: ResultTypeSchema.default('complete') +}); + +/** + * 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' + | 'subscriptions/listen'; + +/** 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', {}), + 'subscriptions/listen': dispatchRequest('subscriptions/listen', subscriptionsListenParamsShape) +}; + +/** 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({ + // eslint-disable-next-line unicorn/prefer-top-level-await -- Zod `.catch()`, not a Promise + ttlMs: z.number().int().min(0).catch(0), + // eslint-disable-next-line unicorn/prefer-top-level-await -- Zod `.catch()`, not a Promise + cacheScope: z.enum(['public', 'private']).catch('private'), + supportedVersions: z.array(z.string()), + capabilities: ServerCapabilities2026Schema, + serverInfo: ImplementationSchema, + instructions: z.string().optional() + }), + // `subscriptions/listen` receives a JSON-RPC result only on a server-side + // graceful close (the empty `SubscriptionsListenResult` — `_meta` carries + // the subscriptionId stamp). The dispatch result schema stays the lifted + // empty body so the mapped type is total; the listen-response demux is + // entry-layer (`Client._onresponse`) and never reaches `decodeResult`. + 'subscriptions/listen': liftedResult({}) +}; + +/* ------------------------------------------------------------------------ * + * Notifications. The 2026 notification set: cancelled, progress, message, + * resources/updated, resources/list_changed, tools/list_changed, + * prompts/list_changed. Deleted: initialized, roots/list_changed, + * tasks/status, elicitation/complete (removed from the draft together with + * URL-elicitation's elicitationId — both remain 2025-11-25 vocabulary only). + * The shapes are revision-identical to the shared schemas, which are + * composed by reference, EXCEPT cancelled (forks below: this revision + * requires `requestId`) and the 2026-only subscriptions/acknowledged. + * ------------------------------------------------------------------------ */ + +/** + * Notification `_meta` (anchor `NotificationMetaObject`): loose, with the + * subscriptions/listen demux key typed when present. Only the anchor-exact + * SHAPE is modeled here — listen delivery itself (filter gating, demux, + * teardown) is #14 scope and not implemented by this module. + */ +export const NotificationMetaSchema = z.looseObject({ + /** + * The JSON-RPC ID of the `subscriptions/listen` request that opened the + * stream a notification was delivered on; absent on notifications not + * delivered via a subscription stream. + */ + 'io.modelcontextprotocol/subscriptionId': RequestIdSchema.optional() +}); + +/** Anchor SubscriptionsAcknowledgedNotification (2026-only). */ +export const SubscriptionsAcknowledgedNotificationSchema = z.object({ + method: z.literal('notifications/subscriptions/acknowledged'), + params: z.object({ + _meta: NotificationMetaSchema.optional(), + notifications: SubscriptionFilterSchema + }) +}); + +/** + * 2026-era `notifications/cancelled` params (anchor-exact fork): `requestId` + * is REQUIRED on this revision — the shared schema keeps it optional because + * the frozen 2025-11-25 shape declares it optional (task cancellation goes + * through `tasks/cancel` there). Requiredness is bare because no 2025-era + * traffic touches this module. + */ +export const CancelledNotificationParamsSchema = z.object({ + _meta: NotificationMetaSchema.optional(), + /** + * The ID of the request to cancel. This MUST correspond to the ID of a + * request the client previously issued. + */ + requestId: RequestIdSchema, + /** + * An optional string describing the reason for the cancellation. This MAY + * be logged or presented to the user. + */ + reason: z.string().optional() +}); + +/** 2026-era `notifications/cancelled` (see the params fork above). */ +export const CancelledNotificationSchema = z.object({ + method: z.literal('notifications/cancelled'), + params: CancelledNotificationParamsSchema +}); + +/** 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/subscriptions/acknowledged'; + +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/subscriptions/acknowledged': SubscriptionsAcknowledgedNotificationSchema +}; + +/* ------------------------------------------------------------------------ * + * 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); +// The multi-round-trip methods may answer with either their final result or an +// InputRequiredResult (anchor: `result: CallToolResult | InputRequiredResult`). +export const CallToolResultResponseSchema = wireResultResponse(z.union([CallToolResultSchema, InputRequiredResultSchema])); +export const ListToolsResultResponseSchema = wireResultResponse(ListToolsResultSchema); +export const ListPromptsResultResponseSchema = wireResultResponse(ListPromptsResultSchema); +export const GetPromptResultResponseSchema = wireResultResponse(z.union([GetPromptResultSchema, InputRequiredResultSchema])); +export const ListResourcesResultResponseSchema = wireResultResponse(ListResourcesResultSchema); +export const ListResourceTemplatesResultResponseSchema = wireResultResponse(ListResourceTemplatesResultSchema); +export const ReadResourceResultResponseSchema = wireResultResponse(z.union([ReadResourceResultSchema, InputRequiredResultSchema])); +export const CompleteResultResponseSchema = wireResultResponse(CompleteResultSchema); +export const DiscoverResultResponseSchema = wireResultResponse(DiscoverResultSchema); diff --git a/packages/core/src/wire/textFallback.ts b/packages/core/src/wire/textFallback.ts new file mode 100644 index 0000000000..5518453480 --- /dev/null +++ b/packages/core/src/wire/textFallback.ts @@ -0,0 +1,23 @@ +import type { CallToolResult } from '../types/types.js'; + +/** + * SEP-2106 §4.3 TextContent auto-append, era-agnostic, called from BOTH + * codecs' {@link WireCodec.projectCallToolResult}: when `structuredContent` + * is a non-object value (array/primitive/`null`) and the handler authored no + * `type:'text'` block, append `{type:'text', text: JSON.stringify(value)}`. + * Object-shaped (or absent) `structuredContent` returns the same reference. + * + * Leaf module: imported by both era codec modules, so it must NOT import from + * `./codec.js` (which value-imports the rev codecs at top level — that would + * make a runtime cycle and a TDZ hazard for entries that evaluate a rev codec + * module first). + */ +export function appendTextFallbackForNonObject(result: CallToolResult): CallToolResult { + const sc = result.structuredContent; + if (sc === undefined) return result; + const isNonObjectValue = typeof sc !== 'object' || sc === null || Array.isArray(sc); + if (!isNonObjectValue) return result; + const hasTextContent = result.content?.some(c => c.type === 'text') ?? false; + if (hasTextContent) return result; + return { ...result, content: [...(result.content ?? []), { type: 'text' as const, text: JSON.stringify(sc) }] }; +} 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..0742eb9974 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ElicitRequestURLParams/elicit-sensitive-data.json @@ -0,0 +1,5 @@ +{ + "mode": "url", + "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/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/HeaderMismatchError/header-mismatch.json b/packages/core/test/corpus/fixtures/2026-07-28/HeaderMismatchError/header-mismatch.json new file mode 100644 index 0000000000..254f7fff46 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/HeaderMismatchError/header-mismatch.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32020, + "message": "Header mismatch: Mcp-Name header value 'foo' does not match body value 'bar'" + } +} 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..1e1daedc7c --- /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": -32021, + "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..1fb5771b28 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/PromptListChangedNotification/prompts-list-changed.json @@ -0,0 +1,9 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/prompts/list_changed", + "params": { + "_meta": { + "io.modelcontextprotocol/subscriptionId": "listen-1" + } + } +} 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..8c894de0ed --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ResourceListChangedNotification/resources-list-changed.json @@ -0,0 +1,9 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/resources/list_changed", + "params": { + "_meta": { + "io.modelcontextprotocol/subscriptionId": "listen-1" + } + } +} 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..0b85f54ba7 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ResourceUpdatedNotification/file-resource-updated-notification.json @@ -0,0 +1,10 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/resources/updated", + "params": { + "_meta": { + "io.modelcontextprotocol/subscriptionId": "listen-1" + }, + "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/SubscriptionsListenResult/listen-closed.json b/packages/core/test/corpus/fixtures/2026-07-28/SubscriptionsListenResult/listen-closed.json new file mode 100644 index 0000000000..2e7e507206 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/SubscriptionsListenResult/listen-closed.json @@ -0,0 +1,6 @@ +{ + "resultType": "complete", + "_meta": { + "io.modelcontextprotocol/subscriptionId": "listen-1" + } +} 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..89cd4e1a6f --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/ToolListChangedNotification/tools-list-changed.json @@ -0,0 +1,9 @@ +{ + "jsonrpc": "2.0", + "method": "notifications/tools/list_changed", + "params": { + "_meta": { + "io.modelcontextprotocol/subscriptionId": "listen-1" + } + } +} 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..e241ba0d67 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/UnsupportedProtocolVersionError/unsupported-version.json @@ -0,0 +1,15 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32022, + "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..ef08b0f827 --- /dev/null +++ b/packages/core/test/corpus/fixtures/2026-07-28/manifest.json @@ -0,0 +1,315 @@ +{ + "revision": "2026-07-28", + "source": { + "repo": "modelcontextprotocol/modelcontextprotocol", + "path": "schema/draft/examples", + "commit": "f68d864a813754e188c6df52dcc5772a12f96c63" + }, + "regenerate": "pnpm fetch:spec-examples --spec-dir # or [sha] to fetch from GitHub", + "directoryCount": 87, + "fileCount": 128, + "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" + ], + "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" + ], + "HeaderMismatchError": [ + "header-mismatch.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" + ], + "SubscriptionsListenResult": [ + "listen-closed.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..82cac4d58b --- /dev/null +++ b/packages/core/test/corpus/schema-twins/2026-07-28.schema.json @@ -0,0 +1,3934 @@ +{ + "$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\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", + "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 is sent by the client to indicate that it is cancelling a request it previously issued.\n\nOn stdio, the server also sends this notification, solely to terminate a {@link SubscriptionsListenRequestsubscriptions/listen} stream: it references the ID of the `subscriptions/listen` request that opened the stream. Servers MUST NOT use this notification to cancel any other 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/NotificationMetaObject" + }, + "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 the client previously issued." + } + }, + "required": [ + "requestId" + ], + "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": { + "description": "This notification is sent by the client to indicate that it is cancelling a request it previously issued.\n\nOn stdio, the server also sends this notification, solely to terminate a {@link SubscriptionsListenRequestsubscriptions/listen} stream: it references the ID of the `subscriptions/listen` request that opened the stream. Servers MUST NOT use this notification to cancel any other 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" + }, + "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\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", + "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": { + "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": [ + "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" + }, + "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" + }, + "HeaderMismatchError": { + "description": "Returned when a server rejects a request because the values in the HTTP\nheaders do not match the corresponding values in the request body, or\nbecause required headers are missing or malformed. For HTTP, the response\nstatus code MUST be `400 Bad Request`.", + "properties": { + "error": { + "allOf": [ + { + "$ref": "#/$defs/Error" + }, + { + "properties": { + "code": { + "const": -32020, + "type": "integer" + } + }, + "required": [ + "code" + ], + "type": "object" + } + ] + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "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\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", + "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\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", + "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\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", + "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": { + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + } + }, + "type": "object" + } + }, + "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\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", + "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/NotificationMetaObject" + }, + "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} (`-32021`).", + "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": -32021, + "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" + }, + "NotificationMetaObject": { + "description": "Extends {@link MetaObject} with additional notification-specific fields. All key naming rules from `MetaObject` apply.", + "properties": { + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/$defs/RequestId", + "description": "Identifies the subscription stream a notification was delivered on. The\nserver MUST include this key on every notification delivered via a\n{@link SubscriptionsListenRequestsubscriptions/listen} stream, so the\nclient can correlate the notification with the originating subscription.\nThe key is absent on notifications not delivered via a subscription\nstream (e.g. progress notifications for an in-flight request), which is\nwhy it is optional here.\n\nThe value is the JSON-RPC ID of the `subscriptions/listen` request that\nopened the stream." + } + }, + "type": "object" + }, + "NotificationParams": { + "description": "Common params for any notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/NotificationMetaObject" + } + }, + "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/NotificationMetaObject" + }, + "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 is only delivered on a {@link SubscriptionsListenRequestsubscriptions/listen} stream when the client requested it via the `promptsListChanged` filter field.", + "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\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", + "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 is only delivered on a {@link SubscriptionsListenRequestsubscriptions/listen} stream when the client requested it via the `resourcesListChanged` filter field.", + "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/NotificationMetaObject" + }, + "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" + } + ] + }, + "ServerResult": { + "anyOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/InputRequiredResult" + }, + { + "$ref": "#/$defs/DiscoverResult" + }, + { + "$ref": "#/$defs/ListResourcesResult" + }, + { + "$ref": "#/$defs/ListResourceTemplatesResult" + }, + { + "$ref": "#/$defs/ReadResourceResult" + }, + { + "$ref": "#/$defs/SubscriptionsListenResult" + }, + { + "$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/NotificationMetaObject" + }, + "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" + }, + "SubscriptionsListenResult": { + "description": "The response to a {@link SubscriptionsListenRequestsubscriptions/listen}\nrequest, signalling that the subscription has ended gracefully (for example,\nduring server shutdown). Because the listen stream is long-lived, this result\nis sent only when the server tears the subscription down; an abrupt transport\nclose carries no response. The result body is otherwise empty.", + "properties": { + "_meta": { + "$ref": "#/$defs/SubscriptionsListenResultMeta" + }, + "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": [ + "_meta", + "resultType" + ], + "type": "object" + }, + "SubscriptionsListenResultMeta": { + "description": "Extends {@link MetaObject} with the subscription-stream identifier carried by a\n{@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply.", + "properties": { + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/$defs/RequestId", + "description": "Identifies the subscription stream this response closes, so the client can\ncorrelate it with the originating subscription — mirroring the same key on\nthe stream's notifications. The value is the JSON-RPC ID of the\n`subscriptions/listen` request that opened the stream (and equals this\nresponse's `id`)." + } + }, + "required": [ + "io.modelcontextprotocol/subscriptionId" + ], + "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\nProperty schemas may carry an `x-mcp-header` annotation to mirror the\nargument value into an HTTP header on the Streamable HTTP transport. See\nthe Streamable HTTP transport specification for the validity and\nextraction rules.\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 is only delivered on a {@link SubscriptionsListenRequestsubscriptions/listen} stream when the client requested it via the `toolsListChanged` filter field.", + "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": -32022, + "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..d5f56f2134 --- /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": "f68d864a813754e188c6df52dcc5772a12f96c63" + }, + "files": { + "2026-07-28": { + "sha256": "14398c3dd2c66b9c3f6661fc7a7eaa24174952ed1598d0b7f011b686ba5c4c83", + "bytes": 178613, + "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..8184af7fca --- /dev/null +++ b/packages/core/test/corpus/specCorpus.test.ts @@ -0,0 +1,200 @@ +/** + * 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([ + 'HeaderMismatchError', + '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 = { + // (empty — the subscriptions/listen vocabulary (SEP-1865) burned when + // the entry-handled listen routers landed.) +}; + +/** + * 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 elicitationId-less ElicitRequestURLParams example burned + // when the 2026-era wire module landed the URL-mode elicitation fork as + // part of the multi-round-trip in-band vocabulary.) +}; + +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(87); + expect(manifest.fileCount).toBe(128); + }); + + 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/auth.test.ts b/packages/core/test/shared/auth.test.ts index 770e0c4d48..39ba0cf7e0 100644 --- a/packages/core/test/shared/auth.test.ts +++ b/packages/core/test/shared/auth.test.ts @@ -119,4 +119,19 @@ describe('OAuthClientMetadataSchema', () => { expect(() => OAuthClientMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme'); }); + + it('parses application_type when native or web', () => { + for (const value of ['native', 'web'] as const) { + const parsed = OAuthClientMetadataSchema.parse({ redirect_uris: ['https://app.example.com/cb'], application_type: value }); + expect(parsed.application_type).toBe(value); + } + }); + + it('passes through a non-enum application_type rather than rejecting (tolerant of AS extension values)', () => { + const parsed = OAuthClientMetadataSchema.parse({ + redirect_uris: ['https://app.example.com/cb'], + application_type: 'service' + }); + expect(parsed.application_type).toBe('service'); + }); }); diff --git a/packages/core/test/shared/clientCapabilityRequirements.test.ts b/packages/core/test/shared/clientCapabilityRequirements.test.ts new file mode 100644 index 0000000000..ff25edc6e2 --- /dev/null +++ b/packages/core/test/shared/clientCapabilityRequirements.test.ts @@ -0,0 +1,98 @@ +/** + * The shared client-capability requirement helpers behind the `-32021` + * 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, + requiredClientCapabilitiesForInputRequest, + 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(); + }); + + test('a bare elicitation declaration implies form support (the pre-mode meaning), but not other modes', () => { + // Bare `elicitation: {}` satisfies the form requirement… + expect(missingClientCapabilities({ elicitation: { form: {} } }, { elicitation: {} })).toBeUndefined(); + // …but an explicit mode declaration removes the implication… + expect(missingClientCapabilities({ elicitation: { form: {} } }, { elicitation: { url: {} } })).toEqual({ + elicitation: { form: {} } + }); + // …and the bare declaration never implies URL support. + expect(missingClientCapabilities({ elicitation: { url: {} } }, { elicitation: {} })).toEqual({ elicitation: { url: {} } }); + }); +}); + +describe('requiredClientCapabilitiesForInputRequest', () => { + test('elicitation requirements are mode-aware sub-capabilities', () => { + expect(requiredClientCapabilitiesForInputRequest({ method: 'elicitation/create', params: { mode: 'url' } })).toEqual({ + elicitation: { url: {} } + }); + expect(requiredClientCapabilitiesForInputRequest({ method: 'elicitation/create', params: { mode: 'form' } })).toEqual({ + elicitation: { form: {} } + }); + // Mode omitted defaults to form. + expect(requiredClientCapabilitiesForInputRequest({ method: 'elicitation/create', params: { message: 'Name?' } })).toEqual({ + elicitation: { form: {} } + }); + }); + + test('sampling requires sampling.tools only when tools/toolChoice are present; roots requires roots; other methods are not input requests', () => { + expect(requiredClientCapabilitiesForInputRequest({ method: 'sampling/createMessage', params: { maxTokens: 5 } })).toEqual({ + sampling: {} + }); + expect( + requiredClientCapabilitiesForInputRequest({ method: 'sampling/createMessage', params: { maxTokens: 5, tools: [] } }) + ).toEqual({ sampling: { tools: {} } }); + expect(requiredClientCapabilitiesForInputRequest({ method: 'roots/list' })).toEqual({ roots: {} }); + expect(requiredClientCapabilitiesForInputRequest({ method: 'tools/call' })).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..ae902cd749 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/upgrade-to-v2.md "Wire tightening (every era)". 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..fb5344a1bb --- /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 `-32020` (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_020, 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_022, -32_021, -32_020].sort((a, b) => a - b)); + }); +}); + +describe('the status matrix — header/body mismatch family', () => { + test('the header/body mismatch family is pinned to -32020 (HeaderMismatch) and maps to HTTP 400', () => { + expect(HEADER_MISMATCH_ERROR_CODE).toBe(-32_020); + 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..5108b8e6cf --- /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 `-32020` (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 -32020 (HeaderMismatch), per the published conformance suite. + expect(outcome.settled).toBe(true); + expect(outcome.code).toBe(-32_020); +}; + +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 (-32020 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_022, + 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_022, 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_022, 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..87c28bec3d --- /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 (`-32020` 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_022, 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_022, + 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_020, settled: true }, + rationale: + 'Header/body protocol-version disagreement is a header-validation failure: -32020 (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_020, settled: true }, + rationale: + 'An envelope-less initialize classifies legacy; a modern header on it is a header/body disagreement and answers the ' + + 'same -32020 (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_020, settled: true }, + rationale: + 'The Mcp-Method header must describe the body it accompanies; a disagreement is a header-validation failure and ' + + 'answers -32020 (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_020, 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 -32020 (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_020, settled: true }, + rationale: + 'The Mcp-Method header must describe the notification body it accompanies (validated only when the notification ' + + 'classifies modern); a disagreement answers -32020 (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_022]: 400, + [-32_021]: 400, + [-32_020]: 400 + }); + }); + + test('the table never maps invalid params: the classifier envelope short-circuit is the only -32602 -> 400 source', () => { + expect(Object.keys(LADDER_ERROR_HTTP_STATUS)).not.toContain(String(-32_602)); + expect(httpStatusForErrorCode(-32_602, 'in-band')).toBe(200); + }); + + test('handler-originated errors stay in-band on HTTP 200, whatever their code', () => { + for (const code of [-32_603, -32_602, -32_601, -32_022, -32_002, -32_000, 1234]) { + expect(httpStatusForErrorCode(code, 'in-band')).toBe(200); + } + }); + + test('ladder-originated codes map to their HTTP statuses', () => { + expect(httpStatusForErrorCode(-32_601, 'ladder')).toBe(404); + expect(httpStatusForErrorCode(-32_022, 'ladder')).toBe(400); + expect(httpStatusForErrorCode(-32_021, 'ladder')).toBe(400); + expect(httpStatusForErrorCode(-32_020, 'ladder')).toBe(400); + }); +}); diff --git a/packages/core/test/shared/inputRequired.test.ts b/packages/core/test/shared/inputRequired.test.ts new file mode 100644 index 0000000000..421ed67309 --- /dev/null +++ b/packages/core/test/shared/inputRequired.test.ts @@ -0,0 +1,89 @@ +/** + * The multi-round-trip authoring helpers (M4.1): the `inputRequired()` + * builder family, the `acceptedContent` reader, and the `withInputRequired` + * manual-mode schema wrapper. No nominal brand exists — the builder returns a + * plain `resultType: 'input_required'` value (F-10). + */ +import { describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; + +import { acceptedContent, inputRequired, withInputRequired } from '../../src/shared/inputRequired.js'; +import { isInputRequiredResult } from '../../src/types/guards.js'; +import { validateStandardSchema } from '../../src/util/standardSchema.js'; + +describe('inputRequired() builder', () => { + test('builds a plain discriminated value (no brand) from inputRequests', () => { + const value = inputRequired({ + inputRequests: { confirm: inputRequired.elicit({ message: 'OK?', requestedSchema: { type: 'object', properties: {} } }) } + }); + expect(value.resultType).toBe('input_required'); + expect(Object.getOwnPropertySymbols(value)).toEqual([]); + expect(isInputRequiredResult(value)).toBe(true); + expect(value.inputRequests?.confirm).toMatchObject({ method: 'elicitation/create', params: { mode: 'form', message: 'OK?' } }); + expect(value.requestState).toBeUndefined(); + }); + + test('builds a requestState-only value (load shedding)', () => { + const value = inputRequired({ requestState: 'opaque-blob' }); + expect(value).toEqual({ resultType: 'input_required', requestState: 'opaque-blob' }); + }); + + test('enforces the at-least-one rule', () => { + expect(() => inputRequired({})).toThrow(TypeError); + expect(() => inputRequired({ inputRequests: {} })).toThrow(/at least one/); + }); + + test('hand-built literals discriminate identically (hand-built results are legal)', () => { + expect(isInputRequiredResult({ resultType: 'input_required', requestState: 's' })).toBe(true); + expect(isInputRequiredResult({ resultType: 'complete' })).toBe(false); + expect(isInputRequiredResult({ content: [] })).toBe(false); + expect(isInputRequiredResult(null)).toBe(false); + }); + + test('per-kind constructors produce the embedded request shapes', () => { + expect(inputRequired.elicitUrl({ message: 'go', url: 'https://example.com/auth' })).toEqual({ + method: 'elicitation/create', + params: { mode: 'url', message: 'go', url: 'https://example.com/auth' } + }); + expect(inputRequired.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], maxTokens: 5 })).toEqual({ + method: 'sampling/createMessage', + params: { messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], maxTokens: 5 } + }); + expect(inputRequired.listRoots()).toEqual({ method: 'roots/list' }); + }); +}); + +describe('acceptedContent()', () => { + test('returns the accepted form content for the key', () => { + const responses = { confirm: { action: 'accept', content: { confirm: true } } }; + expect(acceptedContent<{ confirm: boolean }>(responses, 'confirm')).toEqual({ confirm: true }); + }); + + test('returns undefined for missing keys, declined/cancelled responses, and other kinds', () => { + expect(acceptedContent(undefined, 'confirm')).toBeUndefined(); + expect(acceptedContent({}, 'confirm')).toBeUndefined(); + expect(acceptedContent({ confirm: { action: 'decline' } }, 'confirm')).toBeUndefined(); + expect(acceptedContent({ confirm: { action: 'cancel' } }, 'confirm')).toBeUndefined(); + expect(acceptedContent({ confirm: { action: 'accept' } }, 'confirm')).toBeUndefined(); + expect(acceptedContent({ roots: { roots: [] } }, 'roots')).toBeUndefined(); + }); +}); + +describe('withInputRequired()', () => { + const inner = z.object({ content: z.array(z.unknown()) }); + + test('passes input-required values through untouched', async () => { + const wrapped = withInputRequired(inner); + const value = { resultType: 'input_required', requestState: 'blob' }; + const outcome = await validateStandardSchema(wrapped, value); + expect(outcome).toEqual({ success: true, data: value }); + }); + + test('validates complete results against the wrapped schema', async () => { + const wrapped = withInputRequired(inner); + const ok = await validateStandardSchema(wrapped, { content: [] }); + expect(ok.success).toBe(true); + const bad = await validateStandardSchema(wrapped, { nope: true }); + expect(bad.success).toBe(false); + }); +}); diff --git a/packages/core/test/shared/inputRequiredDriver.test.ts b/packages/core/test/shared/inputRequiredDriver.test.ts new file mode 100644 index 0000000000..6f49311060 --- /dev/null +++ b/packages/core/test/shared/inputRequiredDriver.test.ts @@ -0,0 +1,306 @@ +/** + * The multi-round-trip auto-fulfilment driver loop in isolation (M4.1): + * round accounting against the configurable cap, retry-param construction + * (byte-exact requestState echo, bare responses), requestState-only pacing, + * the existing-knob total-timeout bound, and the typed rounds-exceeded error + * carrying the last result. + */ +import { describe, expect, test, vi } from 'vitest'; + +import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; +import { + buildInputRequiredRetryParams, + DEFAULT_INPUT_REQUIRED_AUTO_FULFILL, + DEFAULT_INPUT_REQUIRED_MAX_ROUNDS, + REQUEST_STATE_ONLY_LEG_PACING_MS, + resolveInputRequiredDriverConfig, + runInputRequiredDriver +} from '../../src/shared/inputRequiredDriver.js'; + +const ELICIT_ENTRY = { method: 'elicitation/create', params: { mode: 'form', message: 'Name?' } }; + +describe('driver configuration', () => { + test('defaults: auto-fulfilment on, cap 10 rounds', () => { + expect(DEFAULT_INPUT_REQUIRED_AUTO_FULFILL).toBe(true); + expect(DEFAULT_INPUT_REQUIRED_MAX_ROUNDS).toBe(10); + expect(resolveInputRequiredDriverConfig(undefined)).toEqual({ autoFulfill: true, maxRounds: 10 }); + expect(resolveInputRequiredDriverConfig({ autoFulfill: false, maxRounds: 3 })).toEqual({ autoFulfill: false, maxRounds: 3 }); + }); +}); + +describe('retry params', () => { + test('echoes requestState byte-exact and attaches bare responses without touching original params', () => { + const original = { name: 'deploy', arguments: { env: 'prod' } }; + const params = buildInputRequiredRetryParams(original, { confirm: { action: 'accept', content: { ok: true } } }, 'opaqueÿ☃'); + expect(params).toEqual({ + name: 'deploy', + arguments: { env: 'prod' }, + inputResponses: { confirm: { action: 'accept', content: { ok: true } } }, + requestState: 'opaqueÿ☃' + }); + // The original params object is not mutated. + expect(original).toEqual({ name: 'deploy', arguments: { env: 'prod' } }); + }); + + test('omits requestState when the result carried none, and inputResponses when nothing was fulfilled', () => { + expect(buildInputRequiredRetryParams({ name: 'x' }, undefined, 'state')).toEqual({ name: 'x', requestState: 'state' }); + expect(buildInputRequiredRetryParams({ name: 'x' }, {}, undefined)).toEqual({ name: 'x' }); + expect(buildInputRequiredRetryParams(undefined, undefined, undefined)).toBeUndefined(); + }); +}); + +describe('driver loop', () => { + test('fulfils embedded requests, retries, and resolves with the complete result', async () => { + const dispatched: string[] = []; + const retries: Array | undefined> = []; + const result = await runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 10 }, + method: 'tools/call', + originalParams: { name: 'deploy' }, + firstPayload: { inputRequests: { confirm: ELICIT_ENTRY }, requestState: 'round-1' }, + requestOptions: {}, + hooks: { + dispatchInputRequest: (key, _entry) => { + dispatched.push(key); + return Promise.resolve({ action: 'accept', content: { ok: true } }); + }, + retry: params => { + retries.push(params); + return Promise.resolve({ content: [{ type: 'text', text: 'done' }] }); + } + } + }); + + expect(result).toEqual({ content: [{ type: 'text', text: 'done' }] }); + expect(dispatched).toEqual(['confirm']); + expect(retries).toEqual([ + { + name: 'deploy', + inputResponses: { confirm: { action: 'accept', content: { ok: true } } }, + requestState: 'round-1' + } + ]); + }); + + test('keeps looping while retries return input_required and counts every leg against the cap', async () => { + let retryCount = 0; + const result = await runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 3 }, + method: 'tools/call', + originalParams: { name: 'deploy' }, + firstPayload: { inputRequests: { confirm: ELICIT_ENTRY } }, + requestOptions: {}, + hooks: { + dispatchInputRequest: () => Promise.resolve({ action: 'accept', content: {} }), + retry: () => { + retryCount += 1; + if (retryCount < 3) { + return Promise.resolve({ resultType: 'input_required', inputRequests: { confirm: ELICIT_ENTRY } }); + } + return Promise.resolve({ content: [] }); + } + } + }); + expect(result).toEqual({ content: [] }); + expect(retryCount).toBe(3); + }); + + test('round exhaustion raises the typed error carrying the last input_required payload', async () => { + const outcome = runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 2 }, + method: 'prompts/get', + originalParams: { name: 'p' }, + firstPayload: { inputRequests: { confirm: ELICIT_ENTRY }, requestState: 'state-0' }, + requestOptions: {}, + hooks: { + dispatchInputRequest: () => Promise.resolve({ action: 'accept' }), + retry: () => + Promise.resolve({ resultType: 'input_required', inputRequests: { again: ELICIT_ENTRY }, requestState: 'state-n' }) + } + }); + await expect(outcome).rejects.toSatisfy((error: unknown) => { + expect(error).toBeInstanceOf(SdkError); + const typed = error as SdkError; + expect(typed.code).toBe(SdkErrorCode.InputRequiredRoundsExceeded); + expect(typed.data).toMatchObject({ + rounds: 2, + lastResult: { inputRequests: { again: ELICIT_ENTRY }, requestState: 'state-n' } + }); + return true; + }); + }); + + test('a requestState-only leg is paced by the fixed delay and counted in the same cap', async () => { + vi.useFakeTimers(); + try { + let resolved = false; + const run = runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 10 }, + method: 'tools/call', + originalParams: { name: 'x' }, + firstPayload: { inputRequests: {}, requestState: 'only-state' }, + requestOptions: {}, + hooks: { + dispatchInputRequest: () => Promise.reject(new Error('must not dispatch on a state-only leg')), + retry: params => { + expect(params).toEqual({ name: 'x', requestState: 'only-state' }); + return Promise.resolve({ content: [] }); + } + } + }).then(value => { + resolved = true; + return value; + }); + + // Nothing happens before the pacing delay elapses. + await vi.advanceTimersByTimeAsync(REQUEST_STATE_ONLY_LEG_PACING_MS - 1); + expect(resolved).toBe(false); + await vi.advanceTimersByTimeAsync(2); + await expect(run).resolves.toEqual({ content: [] }); + } finally { + vi.useRealTimers(); + } + }); + + test('maxTotalTimeout bounds the whole flow through the existing knob (shrinking per-leg budgets)', async () => { + const legBudgets: Array = []; + let now = 0; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); + try { + const outcome = runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 10 }, + method: 'tools/call', + originalParams: { name: 'x' }, + firstPayload: { inputRequests: { confirm: ELICIT_ENTRY } }, + requestOptions: { timeout: 1_000, maxTotalTimeout: 5_000 }, + hooks: { + dispatchInputRequest: () => { + // Handler time counts against the total budget. + now += 3_000; + return Promise.resolve({ action: 'accept' }); + }, + retry: (_params, legOptions) => { + legBudgets.push(legOptions.maxTotalTimeout); + return Promise.resolve({ resultType: 'input_required', inputRequests: { confirm: ELICIT_ENTRY } }); + } + } + }); + await expect(outcome).rejects.toSatisfy((error: unknown) => { + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.RequestTimeout); + return true; + }); + // First leg got the remaining 2 s of the 5 s budget; the second + // round's budget was already exhausted before sending. + expect(legBudgets).toEqual([2_000]); + } finally { + nowSpy.mockRestore(); + } + }); + + test('the total-timeout budget is measured from the flow start (the original request), not the driver start', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => 10_000); + try { + const retries: unknown[] = []; + const outcome = runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 10 }, + method: 'tools/call', + originalParams: { name: 'x' }, + firstPayload: { inputRequests: { confirm: ELICIT_ENTRY } }, + requestOptions: { maxTotalTimeout: 5_000 }, + // The original request went out at t=4s; the first wire leg + // alone already exhausted the 5 s whole-flow budget by t=10s. + flowStartedAt: 4_000, + hooks: { + dispatchInputRequest: () => Promise.resolve({ action: 'accept' }), + retry: params => { + retries.push(params); + return Promise.resolve({ content: [] }); + } + } + }); + await expect(outcome).rejects.toSatisfy((error: unknown) => { + expect(error).toBeInstanceOf(SdkError); + const typed = error as SdkError; + expect(typed.code).toBe(SdkErrorCode.RequestTimeout); + expect(typed.data).toMatchObject({ maxTotalTimeout: 5_000, totalElapsed: 6_000 }); + return true; + }); + // Fail before any retry hits the wire: the budget was already gone. + expect(retries).toHaveLength(0); + } finally { + nowSpy.mockRestore(); + } + }); + + test('each round is surfaced as synthetic progress to the caller', async () => { + const progress: number[] = []; + await runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 10 }, + method: 'resources/read', + originalParams: { uri: 'file:///x' }, + firstPayload: { inputRequests: { confirm: ELICIT_ENTRY } }, + requestOptions: { onprogress: update => progress.push(update.progress) }, + hooks: { + dispatchInputRequest: () => Promise.resolve({ action: 'accept' }), + retry: () => Promise.resolve({ contents: [] }) + } + }); + expect(progress).toEqual([1]); + }); + + test('a failing embedded dispatch aborts its sibling dispatches via the per-round signal', async () => { + let siblingSignal: AbortSignal | undefined; + const siblingSettled = vi.fn(); + const outcome = runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 10 }, + method: 'tools/call', + originalParams: { name: 't' }, + firstPayload: { inputRequests: { fail: ELICIT_ENTRY, slow: ELICIT_ENTRY } }, + requestOptions: {}, + hooks: { + dispatchInputRequest: (key, _entry, signal) => { + if (key === 'fail') { + return Promise.reject(new SdkError(SdkErrorCode.CapabilityNotSupported, 'no handler')); + } + siblingSignal = signal; + return new Promise((resolve, reject) => { + signal.addEventListener('abort', () => { + siblingSettled(); + reject(signal.reason); + }); + }); + }, + retry: () => Promise.resolve({ content: [] }) + } + }); + await expect(outcome).rejects.toMatchObject({ code: SdkErrorCode.CapabilityNotSupported }); + // The sibling was aborted via the linked per-round signal — it did not + // keep running after the first failure. + expect(siblingSignal?.aborted).toBe(true); + expect(siblingSettled).toHaveBeenCalledOnce(); + }); + + test('the requestState-only pacing sleep honors the caller abort signal', async () => { + const controller = new AbortController(); + const outcome = runInputRequiredDriver({ + config: { autoFulfill: true, maxRounds: 10 }, + method: 'tools/call', + originalParams: { name: 't' }, + firstPayload: { inputRequests: {}, requestState: 'opaque' }, + requestOptions: {}, + signal: controller.signal, + hooks: { + dispatchInputRequest: () => Promise.resolve({}), + retry: () => Promise.resolve({ content: [] }) + } + }); + // Abort while the loop is in the 250 ms pacing sleep — the call must + // settle without waiting it out. + const aborted = new SdkError(SdkErrorCode.RequestTimeout, 'aborted'); + controller.abort(aborted); + const start = Date.now(); + await expect(outcome).rejects.toBe(aborted); + expect(Date.now() - start).toBeLessThan(REQUEST_STATE_ONLY_LEG_PACING_MS); + }); +}); diff --git a/packages/core/test/shared/inputRequiredEngine.test.ts b/packages/core/test/shared/inputRequiredEngine.test.ts new file mode 100644 index 0000000000..2ba089808c --- /dev/null +++ b/packages/core/test/shared/inputRequiredEngine.test.ts @@ -0,0 +1,83 @@ +/** + * The multi-round-trip auto-fulfilment engine wiring (the layer between the + * funnel hook and the driver loop): the per-retry-leg request-options + * whitelist, the input-responses partition, and the synthesized embedded + * dispatch context. + */ +import { describe, expect, test } from 'vitest'; + +import { + buildRetryLegRequestOptions, + partitionInputResponses, + synthesizeInputRequestContext +} from '../../src/shared/inputRequiredEngine.js'; + +describe('per-retry-leg request options whitelist', () => { + test('only the whitelisted fields carry over — resumption tokens and the related-request id never do', () => { + const controller = new AbortController(); + const onprogress = (): void => undefined; + const onresumptiontoken = (): void => undefined; + const built = buildRetryLegRequestOptions( + { + signal: controller.signal, + onprogress, + resetTimeoutOnProgress: true, + timeout: 9_999, + maxTotalTimeout: 99_999, + relatedRequestId: 'outer', + resumptionToken: 'tok-123', + onresumptiontoken + }, + { timeout: 5_000, maxTotalTimeout: 60_000 } + ); + expect(built).toEqual({ + signal: controller.signal, + onprogress, + resetTimeoutOnProgress: true, + timeout: 5_000, + maxTotalTimeout: 60_000, + allowInputRequired: true + }); + // The originating call's transport-send options are scoped to the + // originating wire leg only. + expect('resumptionToken' in built).toBe(false); + expect('onresumptiontoken' in built).toBe(false); + expect('relatedRequestId' in built).toBe(false); + }); + + test('absent caller options yield only the manual primitive opt-in', () => { + expect(buildRetryLegRequestOptions(undefined, {})).toEqual({ allowInputRequired: true }); + }); + + test('per-request headers (SEP-2243 Mcp-Param-*) carry to retry legs — arguments are unchanged on retry', () => { + const headers = { 'Mcp-Param-Region': 'us-west1' }; + const built = buildRetryLegRequestOptions({ headers }, {}); + expect(built).toEqual({ headers, allowInputRequired: true }); + }); +}); + +describe('inputResponses partition', () => { + test('bare entries are accepted; wrapped {method, result} entries and non-objects are dropped by key', () => { + const { accepted, droppedKeys } = partitionInputResponses({ + confirm: { action: 'accept', content: { ok: true } }, + wrapped: { method: 'elicitation/create', result: { action: 'accept' } }, + bad: 7 + }); + expect(accepted).toEqual({ confirm: { action: 'accept', content: { ok: true } } }); + expect(droppedKeys.sort()).toEqual(['bad', 'wrapped']); + }); +}); + +describe('synthesized embedded dispatch context', () => { + test('id is the inputRequests key, the supplied signal chains through, and related send/notify are unavailable', () => { + const controller = new AbortController(); + const ctx = synthesizeInputRequestContext('confirm', 'elicitation/create', { _meta: { x: 1 } }, controller.signal, 'sess-1'); + expect(ctx.mcpReq.id).toBe('confirm'); + expect(ctx.mcpReq.method).toBe('elicitation/create'); + expect(ctx.mcpReq.signal).toBe(controller.signal); + expect(ctx.sessionId).toBe('sess-1'); + expect(() => ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 0, progress: 1 } })).toThrowError( + /not available while fulfilling an embedded input request/ + ); + }); +}); diff --git a/packages/core/test/shared/inputRequiredFunnel.test.ts b/packages/core/test/shared/inputRequiredFunnel.test.ts new file mode 100644 index 0000000000..c08904ed7e --- /dev/null +++ b/packages/core/test/shared/inputRequiredFunnel.test.ts @@ -0,0 +1,162 @@ +/** + * Protocol-layer seams of the multi-round-trip flow (M4.1): + * + * - the manual path: `allowInputRequired: true` hands the discriminated + * input-required value back to the caller (the primitive the auto driver is + * layered over), discriminated raw and BEFORE any consumer schema runs; + * - the inbound retry-material partition: only BARE inputResponses entries + * surface to handlers; wrapped `{method, result}` entries are dropped into + * `ctx.mcpReq.droppedInputResponseKeys` (T1/D-059). + */ +import { describe, expect, test } 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 { JSONRPCRequest } from '../../src/types/index.js'; +import { isInputRequiredResult } from '../../src/types/guards.js'; +import { InMemoryTransport } from '../../src/util/inMemory.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 INPUT_REQUIRED_BODY = { + resultType: 'input_required', + inputRequests: { 'elicit-1': { method: 'elicitation/create', params: { mode: 'form', message: 'Name?' } } }, + requestState: 'opaque-state' +}; + +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); + setNegotiatedProtocolVersion(protocol, '2026-07-28'); + return protocol; +} + +describe('manual mode (allowInputRequired)', () => { + test('hands the discriminated input-required value back to the caller', async () => { + const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY); + + const result = await protocol.request( + { method: 'tools/call', params: { name: 'echo', arguments: {} } }, + { + allowInputRequired: true + } + ); + + expect(isInputRequiredResult(result)).toBe(true); + expect(result).toEqual({ + resultType: 'input_required', + inputRequests: INPUT_REQUIRED_BODY.inputRequests, + requestState: 'opaque-state' + }); + + await protocol.close(); + }); + + test('discrimination happens on the raw body, before the consumer-provided result schema runs', async () => { + const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY); + + let schemaInvoked = false; + const poisonedSchema = z.unknown().transform(value => { + schemaInvoked = true; + return value; + }); + + const result = await protocol.request({ method: 'tools/call', params: { name: 'echo' } }, poisonedSchema, { + allowInputRequired: true + }); + expect(isInputRequiredResult(result)).toBe(true); + expect(schemaInvoked, 'the consumer schema must never see the input_required body').toBe(false); + + await protocol.close(); + }); + + test('without the opt-in (and without a driver) the typed local error is unchanged', async () => { + const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY); + await expect(protocol.request({ method: 'tools/call', params: { name: 'echo' } })).rejects.toMatchObject({ + code: 'UNSUPPORTED_RESULT_TYPE', + data: { resultType: 'input_required', method: 'tools/call' } + }); + await protocol.close(); + }); + + test('an input_required carrying neither inputRequests nor requestState fails fast as an invalid result, even with the opt-in', async () => { + const protocol = await wireWithRawResult({ resultType: 'input_required' }); + await expect( + protocol.request({ method: 'tools/call', params: { name: 'echo', arguments: {} } }, { allowInputRequired: true }) + ).rejects.toMatchObject({ + code: 'INVALID_RESULT', + data: { method: 'tools/call', violation: 'input-required-missing-both' } + }); + await protocol.close(); + }); +}); + +describe('era gate (in-band vocabulary grants no registry membership)', () => { + test('the demoted methods are absent from the 2026-07-28 wire-request registry even though their in-band schemas exist', () => { + for (const method of ['elicitation/create', 'sampling/createMessage', 'roots/list']) { + expect(rev2026Codec.inputRequestSchema(method), method).toBeDefined(); + // A peer sending one of these as a wire request on the 2026 era + // still answers −32601 by absence — the in-band fallback used for + // embedded dispatch must never grant wire-request membership. + expect(rev2026Codec.hasRequestMethod(method), method).toBe(false); + } + }); +}); + +describe('inbound retry material (T1/D-059)', () => { + test('bare entries surface on ctx.mcpReq.inputResponses; wrapped entries are dropped into droppedInputResponseKeys', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const receiver = new TestProtocol(); + const seen: Array = []; + receiver.setRequestHandler('tools/call', (_request, ctx) => { + seen.push(ctx.mcpReq); + return { content: [] }; + }); + await receiver.connect(serverTx); + await clientTx.start(); + + const responses = new Promise(resolve => { + clientTx.onmessage = () => resolve(); + }); + await clientTx.send({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'deploy', + arguments: {}, + inputResponses: { + bare: { action: 'accept', content: { ok: true } }, + wrapped: { method: 'elicitation/create', result: { action: 'accept' } }, + 'not-an-object': 42 + }, + requestState: 'echoed-back' + } + } as Parameters[0]); + await responses; + + expect(seen).toHaveLength(1); + const mcpReq = seen[0]!; + expect(mcpReq.inputResponses).toEqual({ bare: { action: 'accept', content: { ok: true } } }); + expect(mcpReq.droppedInputResponseKeys?.sort()).toEqual(['not-an-object', 'wrapped']); + expect(mcpReq.requestState).toBe('echoed-back'); + // The handler-visible params never carry the lifted retry material. + await receiver.close(); + await clientTx.close(); + }); +}); diff --git a/packages/core/test/shared/mcpParamHeaders.test.ts b/packages/core/test/shared/mcpParamHeaders.test.ts new file mode 100644 index 0000000000..e4e3bca6be --- /dev/null +++ b/packages/core/test/shared/mcpParamHeaders.test.ts @@ -0,0 +1,354 @@ +/** + * SEP-2243 `Mcp-Param-*` codec — fixture corpus. + * + * Encoding rows mirror the spec's "Encoding examples" table (and the + * sentinel-collision rule); the constraint rows mirror the published + * conformance referee's `http-invalid-tool-headers` scenario; the + * server-validation rows cover the spec's server-behavior table including the + * two checks the conformance manifest leaves globally untested + * (`sep-2243-server-not-expect-null`, `sep-2243-server-reject-missing-required`). + */ +import { describe, expect, test } from 'vitest'; + +import { HEADER_MISMATCH_ERROR_CODE } from '../../src/shared/inboundClassification.js'; +import { + buildMcpParamHeaders, + decodeMcpParamValue, + encodeMcpParamValue, + MCP_PARAM_HEADER_PREFIX, + mcpParamPrimitiveToString, + paramHeaderMismatchRejection, + scanXMcpHeaderDeclarations, + validateMcpParamHeaders, + X_MCP_HEADER_KEY +} from '../../src/shared/mcpParamHeaders.js'; + +/* ------------------------------------------------------------------------ * + * Value encoding (spec table) + * ------------------------------------------------------------------------ */ + +describe('encodeMcpParamValue / decodeMcpParamValue — spec encoding-examples table', () => { + const CASES: ReadonlyArray<[label: string, input: string, expected: string]> = [ + ['plain ASCII passes through', 'us-west1', 'us-west1'], + ['non-ASCII is Base64-wrapped', 'Hello, 世界', '=?base64?SGVsbG8sIOS4lueVjA==?='], + ['leading + trailing whitespace is Base64-wrapped', ' padded ', '=?base64?IHBhZGRlZCA=?='], + ['embedded newline is Base64-wrapped', 'line1\nline2', '=?base64?bGluZTEKbGluZTI=?='], + ['a value matching the sentinel pattern is itself Base64-wrapped', '=?base64?literal?=', '=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?='], + ['the empty string is Base64-wrapped (would otherwise vanish on the wire)', '', '=?base64??='], + ['internal-only spaces stay plain ASCII (RFC 9110 admits SP inside a field value)', 'a b c', 'a b c'], + ['leading-only space is Base64-wrapped', ' lead', `=?base64?${btoa(' lead')}?=`], + ['trailing-only space is Base64-wrapped', 'trail ', `=?base64?${btoa('trail ')}?=`], + ['CR/LF is Base64-wrapped', 'a\r\nb', `=?base64?${btoa('a\r\nb')}?=`], + ['leading tab is Base64-wrapped', '\tindent', `=?base64?${btoa('\tindent')}?=`] + ]; + + for (const [label, input, expected] of CASES) { + test(label, () => { + const encoded = encodeMcpParamValue(input); + expect(encoded).toBe(expected); + expect(decodeMcpParamValue(encoded)).toBe(input); + }); + } + + test('decode passes a non-sentinel value through unchanged', () => { + expect(decodeMcpParamValue('us-west1')).toBe('us-west1'); + }); + + test('CRLF header-injection: encode produces a sentinel value with no CR/LF and round-trips intact', () => { + // Mcp-Param-* and Mcp-Name share this encoder; an attacker-controlled + // value with CR/LF MUST encode to a header-safe form (RFC 9110 token + // alphabet for the sentinel framing, RFC 4648 §4 alphabet for the + // payload — neither contains CR/LF) so it cannot inject a header. + const injection = 'foo\r\nX-Injected: bar'; + const encoded = encodeMcpParamValue(injection); + expect(encoded.startsWith('=?base64?')).toBe(true); + expect(encoded).not.toMatch(/[\r\n]/); + expect(decodeMcpParamValue(encoded)).toBe(injection); + // The Mcp-Name encoding path is the same encodeMcpParamValue call + // (`_applyBodyDerivedHeaders` in the client transport); pin the + // header-safety property here so a future encoder change cannot + // regress it silently. + expect(() => new Headers().set('mcp-name', encoded)).not.toThrow(); + }); + + test('decode rejects invalid Base64 padding inside the sentinel', () => { + expect(decodeMcpParamValue('=?base64?SGVsbG8?=')).toBeUndefined(); + }); + + test('decode rejects non-alphabet characters inside the sentinel', () => { + expect(decodeMcpParamValue('=?base64?SGV%%G8=?=')).toBeUndefined(); + }); +}); + +describe('mcpParamPrimitiveToString — type-conversion rules', () => { + test('string passes through', () => expect(mcpParamPrimitiveToString('a')).toBe('a')); + test('boolean true → "true"', () => expect(mcpParamPrimitiveToString(true)).toBe('true')); + test('boolean false → "false"', () => expect(mcpParamPrimitiveToString(false)).toBe('false')); + test('integer → decimal string', () => expect(mcpParamPrimitiveToString(42)).toBe('42')); + test('negative integer → decimal string', () => expect(mcpParamPrimitiveToString(-7)).toBe('-7')); + test('non-finite is refused', () => expect(mcpParamPrimitiveToString(Number.POSITIVE_INFINITY)).toBeUndefined()); + test('integer outside ±(2^53-1) is refused', () => expect(mcpParamPrimitiveToString(2 ** 53)).toBeUndefined()); + test('object is refused', () => expect(mcpParamPrimitiveToString({})).toBeUndefined()); +}); + +/* ------------------------------------------------------------------------ * + * Declaration scan (constraint rows from http-invalid-tool-headers) + * ------------------------------------------------------------------------ */ + +describe('scanXMcpHeaderDeclarations — constraint table', () => { + const valid = (schema: unknown) => { + const r = scanXMcpHeaderDeclarations(schema); + expect(r.valid).toBe(true); + return r.valid ? r.declarations : []; + }; + const invalid = (schema: unknown) => { + const r = scanXMcpHeaderDeclarations(schema); + expect(r.valid).toBe(false); + return r.valid ? '' : r.reason; + }; + + test('a valid declaration is collected', () => { + const decls = valid({ type: 'object', properties: { region: { type: 'string', [X_MCP_HEADER_KEY]: 'Region' } } }); + expect(decls).toEqual([{ path: ['region'], headerName: 'Region', type: 'string' }]); + }); + + test('declarations at any nesting depth are collected', () => { + const decls = valid({ + type: 'object', + properties: { + outer: { type: 'object', properties: { inner: { type: 'string', [X_MCP_HEADER_KEY]: 'Inner' } } } + } + }); + expect(decls).toEqual([{ path: ['outer', 'inner'], headerName: 'Inner', type: 'string' }]); + }); + + test('a schema with no declarations scans valid with an empty list', () => { + expect(valid({ type: 'object', properties: { a: { type: 'string' } } })).toEqual([]); + }); + + test('empty x-mcp-header value is rejected', () => { + expect(invalid({ type: 'object', properties: { a: { type: 'string', [X_MCP_HEADER_KEY]: '' } } })).toMatch(/non-empty/); + }); + + test('non-token x-mcp-header value (space) is rejected', () => { + expect(invalid({ type: 'object', properties: { a: { type: 'string', [X_MCP_HEADER_KEY]: 'My Region' } } })).toMatch( + /RFC 9110 token/ + ); + }); + + test('object-typed property is rejected', () => { + expect(invalid({ type: 'object', properties: { a: { type: 'object', [X_MCP_HEADER_KEY]: 'Data' } } })).toMatch(/primitive/); + }); + + test('array-typed property is rejected', () => { + expect(invalid({ type: 'object', properties: { a: { type: 'array', [X_MCP_HEADER_KEY]: 'Items' } } })).toMatch(/primitive/); + }); + + test('null-typed property is rejected', () => { + expect(invalid({ type: 'object', properties: { a: { type: 'null', [X_MCP_HEADER_KEY]: 'Nil' } } })).toMatch(/primitive/); + }); + + // Static-reachability MUST: an x-mcp-header anywhere outside the + // properties-only chain invalidates the tool definition. + const REACHABILITY_CASES: ReadonlyArray<[label: string, schema: unknown]> = [ + ['root schema', { type: 'object', [X_MCP_HEADER_KEY]: 'Root' }], + ['under items', { type: 'object', properties: { a: { type: 'array', items: { type: 'string', [X_MCP_HEADER_KEY]: 'Elem' } } } }], + [ + 'under additionalProperties', + { type: 'object', properties: {}, additionalProperties: { type: 'string', [X_MCP_HEADER_KEY]: 'Extra' } } + ], + [ + 'under oneOf', + { type: 'object', oneOf: [{ type: 'object', properties: { a: { type: 'string', [X_MCP_HEADER_KEY]: 'Branch' } } }] } + ], + ['under anyOf', { type: 'object', anyOf: [{ type: 'string', [X_MCP_HEADER_KEY]: 'Branch' }] }], + ['under allOf', { type: 'object', allOf: [{ type: 'string', [X_MCP_HEADER_KEY]: 'Branch' }] }], + ['under not', { type: 'object', not: { type: 'string', [X_MCP_HEADER_KEY]: 'Neg' } }], + ['under if/then/else', { type: 'object', if: {}, then: { type: 'string', [X_MCP_HEADER_KEY]: 'Cond' } }], + [ + 'under $defs (a $ref-within-$defs target)', + { type: 'object', properties: { a: { $ref: '#/$defs/R' } }, $defs: { R: { type: 'string', [X_MCP_HEADER_KEY]: 'Ref' } } } + ], + [ + "under draft-07 'definitions' (legacy alias of $defs)", + { + type: 'object', + properties: { a: { $ref: '#/definitions/R' } }, + definitions: { R: { type: 'string', [X_MCP_HEADER_KEY]: 'Ref' } } + } + ], + [ + 'under dependentSchemas', + { + type: 'object', + dependentSchemas: { foo: { type: 'object', properties: { bar: { type: 'string', [X_MCP_HEADER_KEY]: 'Dep' } } } } + } + ], + ['under unevaluatedProperties', { type: 'object', unevaluatedProperties: { type: 'string', [X_MCP_HEADER_KEY]: 'Unev' } }], + [ + 'under unevaluatedItems', + { type: 'object', properties: { a: { type: 'array', unevaluatedItems: { type: 'string', [X_MCP_HEADER_KEY]: 'Unev' } } } } + ], + ['under propertyNames', { type: 'object', propertyNames: { type: 'string', [X_MCP_HEADER_KEY]: 'PNames' } }], + [ + 'nested: properties → items → properties (the chain passes through items)', + { + type: 'object', + properties: { + a: { type: 'array', items: { type: 'object', properties: { b: { type: 'string', [X_MCP_HEADER_KEY]: 'Deep' } } } } + } + } + ] + ]; + for (const [label, schema] of REACHABILITY_CASES) { + test(`x-mcp-header on a non-statically-reachable position is rejected: ${label}`, () => { + expect(invalid(schema)).toMatch(/statically reachable/); + }); + } + + test('case-insensitively duplicated header name is rejected', () => { + expect( + invalid({ + type: 'object', + properties: { + a: { type: 'string', [X_MCP_HEADER_KEY]: 'MyField' }, + b: { type: 'string', [X_MCP_HEADER_KEY]: 'myfield' } + } + }) + ).toMatch(/unique/); + }); +}); + +/* ------------------------------------------------------------------------ * + * buildMcpParamHeaders — null/absent omission, primitive emission + * ------------------------------------------------------------------------ */ + +describe('buildMcpParamHeaders', () => { + const DECLS = [ + { path: ['region'], headerName: 'Region', type: 'string' }, + { path: ['priority'], headerName: 'Priority', type: 'integer' }, + { path: ['verbose'], headerName: 'Verbose', type: 'boolean' } + ] as const; + + test('present primitive values become headers; null and absent are omitted', () => { + expect(buildMcpParamHeaders(DECLS, { region: 'us-west1', priority: 5, verbose: null })).toEqual({ + 'Mcp-Param-Region': 'us-west1', + 'Mcp-Param-Priority': '5' + }); + }); + + test('a non-primitive value is silently omitted (params validation owns that fault)', () => { + expect(buildMcpParamHeaders([{ path: ['region'], headerName: 'Region', type: 'string' }], { region: { x: 1 } })).toEqual({}); + }); +}); + +/* ------------------------------------------------------------------------ * + * Server-side validation — the spec's server-behavior table + * ------------------------------------------------------------------------ */ + +describe('validateMcpParamHeaders — server-behavior table', () => { + const DECLS = [{ path: ['region'], headerName: 'Region', type: 'string' }] as const; + + test('header present and matching → ok', () => { + const headers = new Headers({ [`${MCP_PARAM_HEADER_PREFIX}Region`]: 'us-west1' }); + expect(validateMcpParamHeaders(DECLS, { region: 'us-west1' }, headers)).toBeUndefined(); + }); + + test('header decodes from Base64 and matches → ok', () => { + const headers = new Headers({ [`${MCP_PARAM_HEADER_PREFIX}Region`]: encodeMcpParamValue('Hello, 世界') }); + expect(validateMcpParamHeaders(DECLS, { region: 'Hello, 世界' }, headers)).toBeUndefined(); + }); + + // sep-2243-server-not-expect-null — globally-untested manifest check, covered here. + test('body value null → server MUST NOT expect the header (a stray header is ignored)', () => { + const headers = new Headers({ [`${MCP_PARAM_HEADER_PREFIX}Region`]: 'whatever' }); + expect(validateMcpParamHeaders(DECLS, { region: null }, headers)).toBeUndefined(); + expect(validateMcpParamHeaders(DECLS, {}, new Headers())).toBeUndefined(); + }); + + // sep-2243-server-reject-missing-required — globally-untested manifest check, covered here. + test('body has the value but the header is absent → reject 400/-32020', () => { + const r = validateMcpParamHeaders(DECLS, { region: 'us-west1' }, new Headers()); + expect(r).toMatchObject({ kind: 'reject', httpStatus: 400, code: HEADER_MISMATCH_ERROR_CODE, cell: 'param-header-missing' }); + }); + + test('header present but disagreeing → reject 400/-32020 with the mismatch in data', () => { + const r = validateMcpParamHeaders(DECLS, { region: 'us-west1' }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}Region`]: 'eu' })); + expect(r).toMatchObject({ + kind: 'reject', + httpStatus: 400, + code: HEADER_MISMATCH_ERROR_CODE, + cell: 'param-header-mismatch', + data: { mismatch: { header: 'Mcp-Param-Region' } } + }); + }); + + test('invalid Base64 sentinel → reject 400/-32020', () => { + const r = validateMcpParamHeaders( + DECLS, + { region: 'Hello' }, + new Headers({ [`${MCP_PARAM_HEADER_PREFIX}Region`]: '=?base64?SGVsbG8?=' }) + ); + expect(r).toMatchObject({ + kind: 'reject', + httpStatus: 400, + code: HEADER_MISMATCH_ERROR_CODE, + cell: 'param-header-invalid-encoding' + }); + }); + + test('integer-typed declarations are compared numerically (42.0 == 42)', () => { + const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const; + expect(validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: '42.0' }))).toBeUndefined(); + }); + + test('number-typed body values that String() in exponent form still compare numerically', () => { + const numDecl = [{ path: ['t'], headerName: 'T', type: 'number' }] as const; + // String(0.0000001) === '1e-7', which is not a canonical decimal — the + // body-side gate is `typeof bodyRaw === 'number'`, NOT the regex, so a + // numerically-equal canonical-decimal header is accepted. + expect( + validateMcpParamHeaders(numDecl, { t: 0.0000001 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}T`]: '0.0000001' })) + ).toBeUndefined(); + // And a numerically-different canonical decimal still rejects. + const r = validateMcpParamHeaders(numDecl, { t: 0.0000001 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}T`]: '0.0000002' })); + expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' }); + }); + + test('numeric comparison only engages for canonical decimals (no hex / exponent coercion)', () => { + const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const; + // Each of these would satisfy `Number(header) === 42` but is NOT the + // body's `'42'`; the strict-decimal gate keeps them on the + // string-comparison path so they reject as a mismatch. + for (const loose of ['0x2a', '4.2e1']) { + const r = validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: loose })); + expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' }); + } + }); + + test('a non-numeric primitive in a number-declared param falls back to string comparison (no false NaN mismatch)', () => { + const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const; + // Identical header/body — must NOT report a header/body disagreement; + // params validation owns the body-vs-schema fault. + expect(validateMcpParamHeaders(intDecl, { n: 'abc' }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: 'abc' }))).toBeUndefined(); + // Different values still reject as a mismatch. + const r = validateMcpParamHeaders(intDecl, { n: 'abc' }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: 'xyz' })); + expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' }); + }); +}); + +describe('paramHeaderMismatchRejection — consumes the inbound-classifier −32020 shape verbatim', () => { + test('shape: 400 / -32020 / settled, with data.mismatch and the same message prefix', () => { + const r = paramHeaderMismatchRejection('param-header-mismatch', 'Mcp-Param-Region', 'body says us-west1'); + expect(r).toEqual({ + kind: 'reject', + rung: 'param-header-validation', + cell: 'param-header-mismatch', + httpStatus: 400, + code: HEADER_MISMATCH_ERROR_CODE, + message: 'Bad Request: the request headers and body disagree: body says us-west1', + data: { mismatch: { header: 'Mcp-Param-Region', body: 'body says us-west1' } }, + settled: true + }); + }); +}); diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index 6e77430d61..dbfc314d53 100644 --- a/packages/core/test/shared/protocol.test.ts +++ b/packages/core/test/shared/protocol.test.ts @@ -4,7 +4,7 @@ import * as z from 'zod/v4'; import type { ZodType } from 'zod/v4'; import type { BaseContext } from '../../src/shared/protocol.js'; -import { mergeCapabilities, Protocol } from '../../src/shared/protocol.js'; +import { mergeCapabilities, Protocol, setNegotiatedProtocolVersion } from '../../src/shared/protocol.js'; import type { Transport, TransportSendOptions } from '../../src/shared/transport.js'; import type { ClientCapabilities, @@ -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 { @@ -818,6 +820,98 @@ describe('protocol tests', () => { expect(wasAborted).toBe(true); }); }); + + // Spec basic/patterns/cancellation §Transport-Specific (2026-07-28): on a + // per-request-stream transport (Streamable HTTP), closing that stream IS + // the cancel signal — no `notifications/cancelled` is sent. Legacy era and + // single-channel transports keep the `notifications/cancelled` POST path. + describe('outbound request cancellation: stream-close vs notifications/cancelled', () => { + /** Mock transport that records the requestSignal it was handed and every outbound message. */ + class PerRequestStreamTransport extends MockTransport { + readonly hasPerRequestStream = true; + sent: JSONRPCMessage[] = []; + lastRequestSignal: AbortSignal | undefined; + override async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { + this.sent.push(message); + this.lastRequestSignal = options?.requestSignal; + } + } + + const cancelledSent = (sent: JSONRPCMessage[]): JSONRPCMessage[] => + sent.filter(m => 'method' in m && m.method === 'notifications/cancelled'); + + test('modern era + per-request-stream transport: abort closes the stream, NO notifications/cancelled', async () => { + const tx = new PerRequestStreamTransport(); + const proto = createTestProtocol(); + await proto.connect(tx); + setNegotiatedProtocolVersion(proto, '2026-07-28'); + + const ac = new AbortController(); + const pending = testRequest(proto, { method: 'example', params: {} }, z.object({}), { signal: ac.signal }); + + // The transport was handed a per-request requestSignal. + expect(tx.lastRequestSignal).toBeInstanceOf(AbortSignal); + expect(tx.lastRequestSignal?.aborted).toBe(false); + + ac.abort('user cancel'); + await expect(pending).rejects.toThrow(); + + // Stream-close IS the signal: requestSignal aborted, no cancelled notification on the wire. + expect(tx.lastRequestSignal?.aborted).toBe(true); + expect(cancelledSent(tx.sent)).toHaveLength(0); + }); + + test('modern era + single-channel transport (no hasPerRequestStream): POSTs notifications/cancelled', async () => { + // stdio / in-memory shape: hasPerRequestStream is undefined. + const sent: JSONRPCMessage[] = []; + const tx = new MockTransport(); + tx.send = async (m: JSONRPCMessage, _opts?: TransportSendOptions) => { + sent.push(m); + }; + const proto = createTestProtocol(); + await proto.connect(tx); + setNegotiatedProtocolVersion(proto, '2026-07-28'); + + const ac = new AbortController(); + const pending = testRequest(proto, { method: 'example', params: {} }, z.object({}), { signal: ac.signal }); + ac.abort('user cancel'); + await expect(pending).rejects.toThrow(); + + // stdio MUST send notifications/cancelled (spec). + expect(cancelledSent(sent)).toHaveLength(1); + }); + + test('legacy era + per-request-stream transport: behavior unchanged — POSTs notifications/cancelled, no requestSignal', async () => { + const tx = new PerRequestStreamTransport(); + const proto = createTestProtocol(); + await proto.connect(tx); + setNegotiatedProtocolVersion(proto, '2025-11-25'); + + const ac = new AbortController(); + const pending = testRequest(proto, { method: 'example', params: {} }, z.object({}), { signal: ac.signal }); + + // Legacy path is byte-identical to before: no requestSignal threaded. + expect(tx.lastRequestSignal).toBeUndefined(); + + ac.abort('user cancel'); + await expect(pending).rejects.toThrow(); + + expect(cancelledSent(tx.sent)).toHaveLength(1); + }); + + test('modern era + per-request-stream transport: timeout aborts the stream, NO notifications/cancelled', async () => { + const tx = new PerRequestStreamTransport(); + const proto = createTestProtocol(); + await proto.connect(tx); + setNegotiatedProtocolVersion(proto, '2026-07-28'); + + const pending = testRequest(proto, { method: 'example', params: {} }, z.object({}), { timeout: 0 }); + await expect(pending).rejects.toThrow(); + + expect(tx.lastRequestSignal?.aborted).toBe(true); + expect(cancelledSent(tx.sent)).toHaveLength(0); + }); + }); }); // (2025-11 experimental test suites removed under SEP-2663; see git history.) @@ -910,3 +1004,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 (−32022): 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(-32022); + 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/protocolDropInboundHook.test.ts b/packages/core/test/shared/protocolDropInboundHook.test.ts new file mode 100644 index 0000000000..40b99452d8 --- /dev/null +++ b/packages/core/test/shared/protocolDropInboundHook.test.ts @@ -0,0 +1,188 @@ +/** + * The protocol-layer drop consult (`Protocol._shouldDropInbound`): + * + * - 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. + * - Returning `'drop'` discards the message without writing any response + * (requests are surfaced via `onerror`, notifications are silent). This is + * the seam the client uses to decline inbound requests on connections that + * negotiated a modern era. Era selection never happens here — era is + * instance state owned by the serving entry. + */ +import { describe, expect, it } from 'vitest'; + +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol } from '../../src/shared/protocol.js'; +import type { + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResultResponse +} from '../../src/types/index.js'; +import { isJSONRPCResultResponse } from '../../src/types/index.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; + +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) => 'drop' | undefined) | undefined; + + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } + + protected override _shouldDropInbound(message: JSONRPCRequest | JSONRPCNotification): '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 = () => 'drop'; + 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. + 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(); + }); + + it('an undefined verdict from an overriding hook also keeps the handler path unchanged', async () => { + const protocol = new HookedProtocol(); + protocol.verdict = () => undefined; + protocol.setRequestHandler('tools/list', () => ({ tools: [] })); + const { peerTx, sent } = await wire(protocol); + + await peerTx.send({ jsonrpc: '2.0', id: 8, method: 'tools/list', params: {} }); + await flush(); + + expect(sent).toHaveLength(1); + expect(isJSONRPCResultResponse(sent[0] as JSONRPCMessage)).toBe(true); + expect((sent[0] as JSONRPCResultResponse).result).toEqual({ tools: [] }); + 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(); + }); + + it('responses are never consulted: an inbound response keeps todays correlation path', async () => { + const protocol = new HookedProtocol(); + protocol.verdict = () => 'drop'; + const { peerTx, sent } = await wire(protocol); + + // An unsolicited response does not reach the hook (it is not a request + // or notification); it surfaces through the response-correlation path. + await peerTx.send({ jsonrpc: '2.0', id: 99, result: {} }); + await flush(); + + expect(protocol.consulted).toHaveLength(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/standardHeaderValidation.test.ts b/packages/core/test/shared/standardHeaderValidation.test.ts new file mode 100644 index 0000000000..935bd66ad7 --- /dev/null +++ b/packages/core/test/shared/standardHeaderValidation.test.ts @@ -0,0 +1,191 @@ +/** + * SEP-2243 standard-header server-side validation + * (`validateStandardRequestHeaders`). + * + * Evaluated by the HTTP entry on a modern-classified request immediately + * after `classifyInboundRequest` returns a modern route: rejects `400` / + * `-32020` (`HeaderMismatch`) when the required `Mcp-Method` header is + * absent, when the required `Mcp-Name` header is absent on a `tools/call` / + * `prompts/get` / `resources/read` request, when the `Mcp-Name` header + * carries an invalid Base64 sentinel, and when its (decoded) value disagrees + * with the body's `params.name` / `params.uri`. Never enforced on + * notifications or on methods without an `Mcp-Name` source. + * + * The classifier itself is left unchanged by these rungs (it stays a + * body-primary router that passes a modern request through when no headers + * are supplied) — this function is the presence/`Mcp-Name` half of the + * standard-header rung the entry layers on top, so the existing + * `inboundClassification` and cell-sheet tests stay byte-untouched. + */ +import { describe, expect, test } from 'vitest'; + +import type { InboundHttpRequest, InboundLadderRejection, InboundModernRoute } from '../../src/shared/inboundClassification.js'; +import { classifyInboundRequest, MCP_NAME_HEADER_SOURCE, validateStandardRequestHeaders } from '../../src/shared/inboundClassification.js'; +import { encodeMcpParamValue } from '../../src/shared/mcpParamHeaders.js'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../src/types/constants.js'; + +const MODERN = '2026-07-28'; +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'std-header-test', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +function modernPost( + method: string, + params: Record, + headers: { mcpMethod?: string; mcpName?: string } = {} +): { request: InboundHttpRequest; route: InboundModernRoute } { + const request: InboundHttpRequest = { + httpMethod: 'POST', + protocolVersionHeader: MODERN, + ...(headers.mcpMethod !== undefined && { mcpMethodHeader: headers.mcpMethod }), + ...(headers.mcpName !== undefined && { mcpNameHeader: headers.mcpName }), + body: { jsonrpc: '2.0', id: 1, method, params: { ...params, _meta: ENVELOPE } } + }; + const outcome = classifyInboundRequest(request); + if (outcome.kind !== 'modern') { + throw new Error(`expected a modern route, got ${outcome.kind}`); + } + return { request, route: outcome }; +} + +function expectRejection(result: InboundLadderRejection | undefined, cell: string): void { + expect(result).toBeDefined(); + expect(result?.kind).toBe('reject'); + expect(result?.cell).toBe(cell); + expect(result?.rung).toBe('standard-header-validation'); + expect(result?.httpStatus).toBe(400); + expect(result?.code).toBe(-32_020); + expect(result?.settled).toBe(true); +} + +describe('SEP-2243 standard-header validation (Mcp-Method presence)', () => { + test('a modern request without an Mcp-Method header is rejected (method-header-missing)', () => { + const { request, route } = modernPost('tools/list', {}); + expectRejection(validateStandardRequestHeaders(request, route), 'method-header-missing'); + }); + + test('a present Mcp-Method header passes for a method with no Mcp-Name source', () => { + const { request, route } = modernPost('tools/list', {}, { mcpMethod: 'tools/list' }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('the Mcp-Method mismatch cell stays inside classifyInboundRequest (precedence over presence)', () => { + // The mismatch is answered by the classifier itself; this function + // never sees a route for that input. Asserted here so the + // standard-header rung's two halves stay observably ordered. + const inbound: InboundHttpRequest = { + httpMethod: 'POST', + protocolVersionHeader: MODERN, + mcpMethodHeader: 'prompts/list', + body: { jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: ENVELOPE } } + }; + const outcome = classifyInboundRequest(inbound); + expect(outcome.kind).toBe('reject'); + expect((outcome as InboundLadderRejection).cell).toBe('method-header-mismatch'); + }); + + test('notifications are never enforced', () => { + const route: InboundModernRoute = { + kind: 'modern', + messageKind: 'notification', + message: { jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 1 } }, + classification: { era: 'modern', revision: MODERN } + }; + expect(validateStandardRequestHeaders({ httpMethod: 'POST' }, route)).toBeUndefined(); + }); +}); + +describe('SEP-2243 standard-header validation (Mcp-Name presence and cross-check)', () => { + test('a tools/call without an Mcp-Name header is rejected (name-header-missing)', () => { + const { request, route } = modernPost('tools/call', { name: 'echo', arguments: {} }, { mcpMethod: 'tools/call' }); + expectRejection(validateStandardRequestHeaders(request, route), 'name-header-missing'); + }); + + test('a resources/read without an Mcp-Name header is rejected and names params.uri', () => { + const { request, route } = modernPost('resources/read', { uri: 'file:///a' }, { mcpMethod: 'resources/read' }); + const result = validateStandardRequestHeaders(request, route); + expectRejection(result, 'name-header-missing'); + expect(result?.message).toContain('params.uri'); + }); + + test('a tools/call whose body has no params.name passes the Mcp-Name presence rung', () => { + // The missing `params.name` is a request-params failure further down + // the ladder; this rung only answers what it can observe. + const { request, route } = modernPost('tools/call', { arguments: {} }, { mcpMethod: 'tools/call' }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('an Mcp-Name header disagreeing with params.name is rejected (name-header-mismatch)', () => { + const { request, route } = modernPost( + 'tools/call', + { name: 'echo', arguments: {} }, + { mcpMethod: 'tools/call', mcpName: 'wrong_tool_name' } + ); + const result = validateStandardRequestHeaders(request, route); + expectRejection(result, 'name-header-mismatch'); + expect((result?.data as { mismatch?: { header?: string } })?.mismatch?.header).toBe('wrong_tool_name'); + }); + + test('a Base64-sentinel Mcp-Name decodes before comparison (matching)', () => { + const { request, route } = modernPost( + 'tools/call', + { name: 'Hello, 世界', arguments: {} }, + { mcpMethod: 'tools/call', mcpName: encodeMcpParamValue('Hello, 世界') } + ); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('a Base64-sentinel Mcp-Name decodes before comparison (mismatch names the decoded value)', () => { + const { request, route } = modernPost( + 'tools/call', + { name: 'echo', arguments: {} }, + { mcpMethod: 'tools/call', mcpName: encodeMcpParamValue('not-echo') } + ); + const result = validateStandardRequestHeaders(request, route); + expectRejection(result, 'name-header-mismatch'); + expect(result?.message).toContain('"not-echo"'); + }); + + test('an invalid Base64 sentinel in Mcp-Name is rejected (name-header-invalid-encoding)', () => { + const { request, route } = modernPost( + 'tools/call', + { name: 'echo', arguments: {} }, + { mcpMethod: 'tools/call', mcpName: '=?base64?SGVs!!!bG8=?=' } + ); + expectRejection(validateStandardRequestHeaders(request, route), 'name-header-invalid-encoding'); + }); + + test('a matching Mcp-Name on a prompts/get passes', () => { + const { request, route } = modernPost('prompts/get', { name: 'greeting' }, { mcpMethod: 'prompts/get', mcpName: 'greeting' }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('a matching Mcp-Name on a resources/read compares against params.uri', () => { + const uri = 'file:///projects/app/config.json'; + const { request, route } = modernPost('resources/read', { uri }, { mcpMethod: 'resources/read', mcpName: uri }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('the Mcp-Name source map covers exactly the spec table', () => { + expect(MCP_NAME_HEADER_SOURCE).toEqual({ 'tools/call': 'name', 'prompts/get': 'name', 'resources/read': 'uri' }); + }); + + test('a method colliding with Object.prototype members is treated as off-table (passes through to dispatch)', () => { + // `constructor` would return Object.prototype.constructor on a bare + // lookup; the Object.hasOwn guard keeps the early-return firing. + const { request, route } = modernPost('constructor', {}, { mcpMethod: 'constructor', mcpName: '=?base64?!!?=' }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); +}); + +describe('classifyInboundRequest is unchanged by the standard-header presence rung', () => { + test('a body-only modern request (no headers passed) still routes modern', () => { + const outcome = classifyInboundRequest({ + httpMethod: 'POST', + body: { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'echo', arguments: {}, _meta: ENVELOPE } } + }); + expect(outcome.kind).toBe('modern'); + }); +}); 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..9bbf5606a6 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,60 @@ /** - * 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'; + +// SEP-2106 adjudication: the public/neutral SDK types widen `structuredContent` (`unknown`) +// and `outputSchema` (open JSON Schema document); the 2025 wire-exact pins target the FROZEN +// copies in `wire/rev2025-11-25/schemas.ts`. The public widening is pinned in +// `types/publicTypeShapes.test.ts`. +type Wire2025CallToolResult = z4.infer; +type Wire2025SamplingMessage = z4.infer; +type Wire2025CreateMessageResultWithTools = z4.infer; +type Wire2025ToolResultContent = z4.infer; +type Wire2025SamplingMessageContentBlock = z4.infer; + +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 +77,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 +127,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 +258,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,23 +282,19 @@ 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) => { + CallToolResult: (sdk: Wire2025CallToolResult, spec: SpecTypes.CallToolResult) => { sdk = spec; spec = sdk; }, @@ -297,11 +331,11 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - SamplingMessage: (sdk: SDKTypes.SamplingMessage, spec: SpecTypes.SamplingMessage) => { + SamplingMessage: (sdk: Wire2025SamplingMessage, spec: SpecTypes.SamplingMessage) => { sdk = spec; spec = sdk; }, - CreateMessageResult: (sdk: SDKTypes.CreateMessageResultWithTools, spec: SpecTypes.CreateMessageResult) => { + CreateMessageResult: (sdk: Wire2025CreateMessageResultWithTools, spec: SpecTypes.CreateMessageResult) => { sdk = spec; spec = sdk; }, @@ -476,48 +510,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; }, @@ -549,11 +574,11 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - ToolResultContent: (sdk: SDKTypes.ToolResultContent, spec: SpecTypes.ToolResultContent) => { + ToolResultContent: (sdk: Wire2025ToolResultContent, spec: SpecTypes.ToolResultContent) => { sdk = spec; spec = sdk; }, - SamplingMessageContentBlock: (sdk: SDKTypes.SamplingMessageContentBlock, spec: SpecTypes.SamplingMessageContentBlock) => { + SamplingMessageContentBlock: (sdk: Wire2025SamplingMessageContentBlock, spec: SpecTypes.SamplingMessageContentBlock) => { sdk = spec; spec = sdk; }, @@ -662,14 +687,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 +727,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 +777,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 +794,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 +866,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..fc58dd0e73 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,116 @@ 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 }>; +// 2026-era cancelled fork (requestId required on this revision) and the +// notification `_meta` shape (anchor NotificationMetaObject). +type WCancelledNotification = z4.infer; +type WCancelledNotificationParams = z4.infer; +type WNotificationMeta = z4.infer; + +/* Subscriptions vocabulary (SEP-1865) — modeled by the 2026-era wire module. */ +type WSubscriptionFilter = z4.infer; +type WSubscriptionsListenRequest = z4.infer; +type WSubscriptionsListenRequestParams = WSubscriptionsListenRequest['params']; +type WSubscriptionsAcknowledgedNotification = z4.infer; +type WSubscriptionsAcknowledgedNotificationParams = WSubscriptionsAcknowledgedNotification['params']; +type WSubscriptionsListenResult = z4.infer; +type WSubscriptionsListenResultMeta = z4.infer; +// The anchor's ClientRequest union, composed from the era module's wire requests. +type WClientRequest = + | WCompleteRequest + | WListPromptsRequest + | WListResourceTemplatesRequest + | WListResourcesRequest + | WListToolsRequest + | WDiscoverRequest + | WCallToolRequest + | WGetPromptRequest + | WReadResourceRequest + | WSubscriptionsListenRequest; +// The anchor's ServerNotification union (cancelled fork; the four +// subscription-gated change notifications use neutral params shapes). +type WServerNotification = + | WCancelledNotification + | SDKTypes.ProgressNotification + | SDKTypes.LoggingMessageNotification + | SDKTypes.ResourceListChangedNotification + | (Omit & { params: { _meta?: WNotificationMeta; uri: string } }) + | SDKTypes.ToolListChangedNotification + | SDKTypes.PromptListChangedNotification + | WSubscriptionsAcknowledgedNotification; + +/* Multi-round-trip vocabulary (SEP-2322) — modeled by the 2026-era wire module. */ +type WInputRequest = z4.infer; +type WInputRequests = z4.infer; +type WInputResponse = z4.infer; +type WInputResponses = z4.infer; +type WInputRequiredResult = z4.infer; +type WInputResponseRequestParams = z4.infer; +type WCreateMessageRequest = z4.infer; +type WCreateMessageRequestParams = z4.infer; +type WCreateMessageResult = z4.infer; +type WElicitRequest = z4.infer; +type WElicitRequestParams = z4.infer; +type WElicitRequestURLParams = z4.infer; +type WElicitResult = z4.infer; +type WListRootsRequest = z4.infer; +type WListRootsResult = z4.infer; +type WCallToolRequest = z4.infer; +type WCallToolRequestParams = WCallToolRequest['params']; +type WGetPromptRequest = z4.infer; +type WGetPromptRequestParams = WGetPromptRequest['params']; +type WReadResourceRequestParamsRetry = WReadResourceRequest['params']; +type WCallToolResultResponse = z4.infer; +type WGetPromptResultResponse = z4.infer; +type WReadResourceResultResponse = z4.infer; +// The anchor's ServerResult union, composed from the era module's wire results. +type WServerResult = + | WResult + | WDiscoverResult + | WCompleteResult + | WGetPromptResult + | WListPromptsResult + | WListResourceTemplatesResult + | WListResourcesResult + | WReadResourceResult + | WCallToolResult + | WListToolsResult + | WSubscriptionsListenResult + | WInputRequiredResult; + const sdkTypeChecks = { JSONValue: (sdk: SDKTypes.JSONValue, spec: SpecTypes.JSONValue) => { sdk = spec; @@ -114,14 +231,6 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - CancelledNotificationParams: (sdk: SDKTypes.CancelledNotificationParams, spec: SpecTypes.CancelledNotificationParams) => { - sdk = spec; - spec = sdk; - }, - CancelledNotification: (sdk: WithJSONRPC, spec: SpecTypes.CancelledNotification) => { - sdk = spec; - spec = sdk; - }, ClientCapabilities: (sdk: SDKTypes.ClientCapabilities, spec: SpecTypes.ClientCapabilities) => { sdk = spec; spec = sdk; @@ -298,18 +407,6 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - ElicitRequestURLParams: (sdk: SDKTypes.ElicitRequestURLParams, spec: SpecTypes.ElicitRequestURLParams) => { - sdk = spec; - spec = sdk; - }, - ElicitRequestParams: (sdk: SDKTypes.ElicitRequestParams, spec: SpecTypes.ElicitRequestParams) => { - sdk = spec; - spec = sdk; - }, - ElicitRequest: (sdk: SDKTypes.ElicitRequest, spec: SpecTypes.ElicitRequest) => { - sdk = spec; - spec = sdk; - }, PrimitiveSchemaDefinition: (sdk: SDKTypes.PrimitiveSchemaDefinition, spec: SpecTypes.PrimitiveSchemaDefinition) => { sdk = spec; spec = sdk; @@ -357,138 +454,389 @@ const sdkTypeChecks = { EnumSchema: (sdk: SDKTypes.EnumSchema, spec: SpecTypes.EnumSchema) => { sdk = spec; spec = sdk; + } +}; + +/* 2026-era wire parity checks (Q1 increment 2) — appended to sdkTypeChecks. */ +const wireParityChecks = { + Result: (sdk: WResult, spec: SpecTypes.Result) => { + sdk = spec; + spec = sdk; + }, + // Cancelled is the one notification this era forks (requestId is REQUIRED + // on 2026-07-28; the shared schema keeps the frozen 2025-11-25 optional + // shape) — compared against the fork, not the neutral type. + CancelledNotificationParams: (sdk: WCancelledNotificationParams, spec: SpecTypes.CancelledNotificationParams) => { + sdk = spec; + spec = sdk; }, - ElicitationCompleteNotification: ( - sdk: WithJSONRPC, - spec: SpecTypes.ElicitationCompleteNotification + CancelledNotification: (sdk: WithJSONRPC, spec: SpecTypes.CancelledNotification) => { + sdk = spec; + spec = sdk; + }, + // The 2026 client-sent notification set is exactly `notifications/cancelled` + // (progress is server→client only on this revision), so the union compares + // against the cancelled fork; HTTP-side cancellation semantics (close the + // stream) are #14 scope and not asserted here. + ClientNotification: (sdk: WithJSONRPC, spec: SpecTypes.ClientNotification) => { + sdk = spec; + spec = sdk; + }, + // Notification `_meta` (anchor NotificationMetaObject): the typed + // subscriptions/listen demux key — shape only; listen delivery is #14. + NotificationMetaObject: (sdk: WNotificationMeta, spec: SpecTypes.NotificationMetaObject) => { + 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; }, - ClientNotification: (sdk: WithJSONRPC, spec: SpecTypes.ClientNotification) => { + 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; + }, + + /* Multi-round-trip vocabulary (SEP-2322, M4.1) */ + InputRequest: (sdk: WInputRequest, spec: SpecTypes.InputRequest) => { + sdk = spec; + spec = sdk; + }, + InputRequests: (sdk: WInputRequests, spec: SpecTypes.InputRequests) => { + sdk = spec; + spec = sdk; + }, + InputResponse: (sdk: WInputResponse, spec: SpecTypes.InputResponse) => { + sdk = spec; + spec = sdk; + }, + InputResponses: (sdk: WInputResponses, spec: SpecTypes.InputResponses) => { + sdk = spec; + spec = sdk; + }, + InputRequiredResult: (sdk: WInputRequiredResult, spec: SpecTypes.InputRequiredResult) => { + sdk = spec; + spec = sdk; + }, + InputResponseRequestParams: (sdk: WInputResponseRequestParams, spec: SpecTypes.InputResponseRequestParams) => { + sdk = spec; + spec = sdk; + }, + CreateMessageRequest: (sdk: WCreateMessageRequest, spec: SpecTypes.CreateMessageRequest) => { + sdk = spec; + spec = sdk; + }, + CreateMessageRequestParams: (sdk: WCreateMessageRequestParams, spec: SpecTypes.CreateMessageRequestParams) => { + sdk = spec; + spec = sdk; + }, + CreateMessageResult: (sdk: WCreateMessageResult, spec: SpecTypes.CreateMessageResult) => { + sdk = spec; + spec = sdk; + }, + // The 2026-era URL-mode elicitation params drop `elicitationId` (the + // shared schema keeps it required for the frozen 2025-11-25 shape) — + // compared against the wire-module fork. + ElicitRequestURLParams: (sdk: WElicitRequestURLParams, spec: SpecTypes.ElicitRequestURLParams) => { + sdk = spec; + spec = sdk; + }, + ElicitRequestParams: (sdk: WElicitRequestParams, spec: SpecTypes.ElicitRequestParams) => { + sdk = spec; + spec = sdk; + }, + ElicitRequest: (sdk: WElicitRequest, spec: SpecTypes.ElicitRequest) => { + sdk = spec; + spec = sdk; + }, + ElicitResult: (sdk: WElicitResult, spec: SpecTypes.ElicitResult) => { + sdk = spec; + spec = sdk; + }, + ListRootsRequest: (sdk: WListRootsRequest, spec: SpecTypes.ListRootsRequest) => { + sdk = spec; + spec = sdk; + }, + ListRootsResult: (sdk: WListRootsResult, spec: SpecTypes.ListRootsResult) => { + sdk = spec; + spec = sdk; + }, + CallToolRequestParams: (sdk: WCallToolRequestParams, spec: SpecTypes.CallToolRequestParams) => { + sdk = spec; + spec = sdk; + }, + CallToolRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CallToolRequest) => { + sdk = spec; + spec = sdk; + }, + GetPromptRequestParams: (sdk: WGetPromptRequestParams, spec: SpecTypes.GetPromptRequestParams) => { + sdk = spec; + spec = sdk; + }, + GetPromptRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.GetPromptRequest) => { + sdk = spec; + spec = sdk; + }, + ReadResourceRequestParams: (sdk: WReadResourceRequestParamsRetry, spec: SpecTypes.ReadResourceRequestParams) => { + sdk = spec; + spec = sdk; + }, + ReadResourceRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ReadResourceRequest) => { + sdk = spec; + spec = sdk; + }, + CallToolResultResponse: (sdk: WCallToolResultResponse, spec: SpecTypes.CallToolResultResponse) => { + sdk = spec; + spec = sdk; + }, + GetPromptResultResponse: (sdk: WGetPromptResultResponse, spec: SpecTypes.GetPromptResultResponse) => { + sdk = spec; + spec = sdk; + }, + ReadResourceResultResponse: (sdk: WReadResourceResultResponse, spec: SpecTypes.ReadResourceResultResponse) => { + sdk = spec; + spec = sdk; + }, + ServerResult: (sdk: WServerResult, spec: SpecTypes.ServerResult) => { + sdk = spec; + spec = sdk; + }, + SubscriptionFilter: (sdk: WSubscriptionFilter, spec: SpecTypes.SubscriptionFilter) => { + sdk = spec; + spec = sdk; + }, + SubscriptionsListenRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.SubscriptionsListenRequest) => { + sdk = spec; + spec = sdk; + }, + SubscriptionsListenRequestParams: (sdk: WSubscriptionsListenRequestParams, spec: SpecTypes.SubscriptionsListenRequestParams) => { + sdk = spec; + spec = sdk; + }, + SubscriptionsAcknowledgedNotification: ( + sdk: WithJSONRPC, + spec: SpecTypes.SubscriptionsAcknowledgedNotification + ) => { + sdk = spec; + spec = sdk; + }, + SubscriptionsAcknowledgedNotificationParams: ( + sdk: WSubscriptionsAcknowledgedNotificationParams, + spec: SpecTypes.SubscriptionsAcknowledgedNotificationParams + ) => { + sdk = spec; + spec = sdk; + }, + SubscriptionsListenResult: (sdk: WSubscriptionsListenResult, spec: SpecTypes.SubscriptionsListenResult) => { + sdk = spec; + spec = sdk; + }, + SubscriptionsListenResultMeta: (sdk: WSubscriptionsListenResultMeta, spec: SpecTypes.SubscriptionsListenResultMeta) => { + sdk = spec; + spec = sdk; + }, + ClientRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ClientRequest) => { + sdk = spec; + spec = sdk; + }, + ServerNotification: (sdk: WithJSONRPC, spec: SpecTypes.ServerNotification) => { 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 + Error: 'the inner error object of a JSONRPCError is inlined in the SDK', - // 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', + // (The M4.1 MRTR and M6.1 subscriptions/listen partitions burned down + // when their wire vocabulary landed in wire/rev2026-07-28 — see the + // wireParityChecks entries above.) - // 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', - - // Response envelopes embedding the changed Result shape → PR for MRTR: - 'JSONRPCResultResponse', - 'JSONRPCResponse', - 'JSONRPCMessage', - 'CallToolResultResponse', - 'CompleteResultResponse', - 'GetPromptResultResponse', - 'ListPromptsResultResponse', - 'ListResourceTemplatesResultResponse', - 'ListResourcesResultResponse', - 'ListToolsResultResponse', - 'ReadResourceResultResponse', - - // 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', - - // 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: + HeaderMismatchError: 'M1.2 validation ladder (#8)', + 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)]; @@ -501,8 +849,8 @@ describe('Spec Types (2026-07-28)', () => { it('pins the 2026-07-28 protocol version and the new error codes', () => { expect(LATEST_PROTOCOL_VERSION).toBe('2026-07-28'); - expect(MISSING_REQUIRED_CLIENT_CAPABILITY).toBe(-32003); - expect(UNSUPPORTED_PROTOCOL_VERSION).toBe(-32004); + expect(MISSING_REQUIRED_CLIENT_CAPABILITY).toBe(-32021); + expect(UNSUPPORTED_PROTOCOL_VERSION).toBe(-32022); expect(ProtocolErrorCode.MissingRequiredClientCapability).toBe(MISSING_REQUIRED_CLIENT_CAPABILITY); expect(ProtocolErrorCode.UnsupportedProtocolVersion).toBe(UNSUPPORTED_PROTOCOL_VERSION); }); @@ -518,7 +866,8 @@ describe('Spec Types (2026-07-28)', () => { expect(specTypes).toContain('DiscoverRequest'); expect(specTypes).toContain('InputRequiredResult'); expect(specTypes).toContain('SubscriptionsListenRequest'); - expect(specTypes).toHaveLength(150); + expect(specTypes).toContain('SubscriptionsListenResult'); + expect(specTypes).toHaveLength(153); }); it('should only allowlist types that exist in the 2026-07-28 schema', () => { @@ -531,7 +880,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 +888,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..c56f83990b 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([]); @@ -489,7 +497,7 @@ describe('Types', () => { expect(result.success).toBe(false); }); - test('should still require type: object at root for outputSchema', () => { + test('SEP-2106: outputSchema accepts any JSON Schema root (the public schema; the 2025 wire schema still rejects)', () => { const tool = { name: 'test', inputSchema: { type: 'object' }, @@ -498,7 +506,7 @@ describe('Types', () => { } }; const result = ToolSchema.safeParse(tool); - expect(result.success).toBe(false); + expect(result.success).toBe(true); }); test('should accept simple minimal schema (backward compatibility)', () => { @@ -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..c6f1377e99 --- /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_022; + 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 -32022 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_022, + 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', () => { + // -32022 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_022, 'unsupported', data); + expect(recognized).toBeInstanceOf(ProtocolError); + expect(recognized).not.toBeInstanceOf(UnsupportedProtocolVersionError); + expect(recognized.code).toBe(-32_022); + } + + // -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..7191cf5e19 --- /dev/null +++ b/packages/core/test/types/errorSurfacePins.test.ts @@ -0,0 +1,204 @@ +/** + * 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, + ResourceNotFoundError, + 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: -32021, + UnsupportedProtocolVersion: -32022, + 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', + InputRequiredRoundsExceeded: 'INPUT_REQUIRED_ROUNDS_EXCEEDED', + ListPaginationExceeded: 'LIST_PAGINATION_EXCEEDED', + 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(-32022, '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(-32022, 'unsupported', { wrong: 'shape' }); + expect(generic).toBeInstanceOf(ProtocolError); + expect(generic).not.toBeInstanceOf(UnsupportedProtocolVersionError); + }); + + test('fromError accepts BOTH -32602 and -32002 as resource-not-found by data.uri shape', () => { + // Cross-bundle data-parse contract: the typed ResourceNotFoundError is + // recognised by `data.uri` being a string on either the spec-mandated + // -32602 or the legacy -32002 (the spec's "clients SHOULD also accept + // -32002" backwards-compatibility clause). The recognition input is the + // bare wire shape — no instanceof on the inbound value. + const onSpecCode = ProtocolError.fromError(-32602, 'Resource not found: file:///x', { uri: 'file:///x' }); + expect(onSpecCode).toBeInstanceOf(ResourceNotFoundError); + expect((onSpecCode as ResourceNotFoundError).uri).toBe('file:///x'); + expect(onSpecCode.code).toBe(-32602); + + const onLegacyCode = ProtocolError.fromError(-32002, 'Resource not found', { uri: 'mem://y' }); + expect(onLegacyCode).toBeInstanceOf(ResourceNotFoundError); + expect((onLegacyCode as ResourceNotFoundError).uri).toBe('mem://y'); + + // -32602 without `data.uri` is an ordinary Invalid Params, not resource-not-found. + const plainInvalid = ProtocolError.fromError(-32602, 'Invalid params', { something: 'else' }); + expect(plainInvalid).not.toBeInstanceOf(ResourceNotFoundError); + expect(plainInvalid.code).toBe(-32602); + }); + + test('fromError does NOT reclassify -32602 as ResourceNotFoundError when data carries uri alongside other keys', () => { + // A server's own Invalid Params with `data.uri` (e.g. a "uri must be + // https" validation error) is NOT a resource-not-found. The discriminator + // on -32602 is "exactly { uri } and nothing else". + const validationError = ProtocolError.fromError(-32602, 'uri must be https', { + uri: 'http://example/x', + reason: 'uri must be https' + }); + expect(validationError).not.toBeInstanceOf(ResourceNotFoundError); + expect(validationError).toBeInstanceOf(ProtocolError); + expect(validationError.code).toBe(-32602); + // -32002 is still recognised on `data.uri` regardless of extra keys — + // the legacy code is itself the discriminator. + const legacyWithExtra = ProtocolError.fromError(-32002, 'Resource not found', { uri: 'mem://y', extra: 1 }); + expect(legacyWithExtra).toBeInstanceOf(ResourceNotFoundError); + }); +}); + +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/errors.test.ts b/packages/core/test/types/errors.test.ts index b908dfb397..0b04ada841 100644 --- a/packages/core/test/types/errors.test.ts +++ b/packages/core/test/types/errors.test.ts @@ -6,10 +6,10 @@ import { ProtocolError, UnsupportedProtocolVersionError } from '../../src/types/ describe('UnsupportedProtocolVersionError', () => { const data = { supported: ['2025-11-25', '2025-06-18'], requested: '2026-07-28' }; - it('carries code -32004 and the supported/requested data', () => { + it('carries code -32022 and the supported/requested data', () => { const error = new UnsupportedProtocolVersionError(data); expect(error.code).toBe(ProtocolErrorCode.UnsupportedProtocolVersion); - expect(error.code).toBe(-32004); + expect(error.code).toBe(-32022); expect(error.supported).toEqual(['2025-11-25', '2025-06-18']); expect(error.requested).toBe('2026-07-28'); expect(error.data).toEqual(data); @@ -23,7 +23,7 @@ describe('UnsupportedProtocolVersionError', () => { }); it('is materialized by ProtocolError.fromError', () => { - const error = ProtocolError.fromError(-32004, 'Unsupported protocol version: 2026-07-28', data); + const error = ProtocolError.fromError(-32022, 'Unsupported protocol version: 2026-07-28', data); expect(error).toBeInstanceOf(UnsupportedProtocolVersionError); if (error instanceof UnsupportedProtocolVersionError) { expect(error.supported).toEqual(['2025-11-25', '2025-06-18']); @@ -34,10 +34,10 @@ describe('UnsupportedProtocolVersionError', () => { it('falls back to a generic ProtocolError when the data is missing or malformed', () => { for (const malformed of [undefined, {}, { supported: 'not-an-array', requested: '2026-07-28' }, { supported: ['2025-11-25'] }]) { - const error = ProtocolError.fromError(-32004, 'unsupported', malformed); + const error = ProtocolError.fromError(-32022, 'unsupported', malformed); expect(error).toBeInstanceOf(ProtocolError); expect(error).not.toBeInstanceOf(UnsupportedProtocolVersionError); - expect(error.code).toBe(-32004); + expect(error.code).toBe(-32022); expect(error.data).toEqual(malformed); } }); diff --git a/packages/core/test/types/missingClientCapabilityError.test.ts b/packages/core/test/types/missingClientCapabilityError.test.ts new file mode 100644 index 0000000000..ce121ff8a9 --- /dev/null +++ b/packages/core/test/types/missingClientCapabilityError.test.ts @@ -0,0 +1,64 @@ +/** + * The `-32021` 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 -32021 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_021); + 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_021, + 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_021, 'missing', undefined)).not.toBeInstanceOf(MissingRequiredClientCapabilityError); + expect(ProtocolError.fromError(-32_021, 'missing', { requiredCapabilities: ['sampling'] })).not.toBeInstanceOf( + MissingRequiredClientCapabilityError + ); + expect(ProtocolError.fromError(-32_021, 'missing', { somethingElse: true })).not.toBeInstanceOf( + MissingRequiredClientCapabilityError + ); + expect(ProtocolError.fromError(-32_021, '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_021 && + typeof (fromAnotherBundle.data as { requiredCapabilities?: unknown } | undefined)?.requiredCapabilities === 'object'; + expect(looksLikeMissingCapability).toBe(true); + }); +}); diff --git a/packages/core/test/types/publicTypeShapes.test.ts b/packages/core/test/types/publicTypeShapes.test.ts new file mode 100644 index 0000000000..5358e80613 --- /dev/null +++ b/packages/core/test/types/publicTypeShapes.test.ts @@ -0,0 +1,73 @@ +/** + * SEP-2106 public-type pins. + * + * The neutral/public schemas in `types/schemas.ts` widen `structuredContent` (any JSON value) + * and `Tool.outputSchema` (any JSON Schema document). The 2025 wire-parse contract is preserved + * via the FROZEN copies in `wire/rev2025-11-25/schemas.ts`. This file pins both: + * - the public TypeScript types carry the widened shapes (type-level pins); + * - the frozen 2025 wire schemas still REJECT the widened vocabulary (runtime pins). + * + * The 2025 spec-anchor parity for these names lives in `spec.types.2025-11-25.test.ts` and + * targets the frozen wire schemas, not the public types. + */ +import { describe, expect, expectTypeOf, it } from 'vitest'; + +import type { + CallToolResult, + CompatibilityCallToolResult, + CreateMessageResultWithTools, + ListToolsResult, + SamplingMessage, + SamplingMessageContentBlock, + Tool, + ToolResultContent +} from '../../src/types/types.js'; +import { + CallToolResultSchema as Wire2025CallToolResultSchema, + ToolSchema as Wire2025ToolSchema +} from '../../src/wire/rev2025-11-25/schemas.js'; + +describe('SEP-2106 public-type widening', () => { + it('CallToolResult.structuredContent is unknown', () => { + expectTypeOf().toEqualTypeOf(); + }); + it('CompatibilityCallToolResult.structuredContent is unknown (on the modern arm)', () => { + expectTypeOf['structuredContent']>().toEqualTypeOf(); + }); + it('ToolResultContent.structuredContent is unknown', () => { + expectTypeOf().toEqualTypeOf(); + }); + it('SamplingMessageContentBlock tool_result arm carries unknown structuredContent', () => { + expectTypeOf['structuredContent']>().toEqualTypeOf(); + }); + it('SamplingMessage.content composes the widened tool_result arm', () => { + type Block = Extract, { type: 'tool_result' }>; + expectTypeOf().toEqualTypeOf(); + }); + it('CreateMessageResultWithTools.content composes the widened tool_result arm', () => { + type Block = Extract, { type: 'tool_result' }>; + expectTypeOf().toEqualTypeOf(); + }); + it('Tool.outputSchema is an open JSON Schema document', () => { + expectTypeOf>().toEqualTypeOf<{ $schema?: string; [k: string]: unknown }>(); + }); + it('ListToolsResult.tools composes the widened Tool', () => { + expectTypeOf>().toEqualTypeOf<{ + $schema?: string; + [k: string]: unknown; + }>(); + }); +}); + +describe('Q10-L2: frozen 2025 wire schemas still reject SEP-2106 vocabulary', () => { + it('Wire2025 CallToolResultSchema rejects non-object structuredContent', () => { + expect(Wire2025CallToolResultSchema.safeParse({ content: [], structuredContent: [1] }).success).toBe(false); + expect(Wire2025CallToolResultSchema.safeParse({ content: [], structuredContent: 0 }).success).toBe(false); + expect(Wire2025CallToolResultSchema.safeParse({ content: [], structuredContent: { ok: true } }).success).toBe(true); + }); + it("Wire2025 ToolSchema rejects non-type:'object' outputSchema", () => { + const base = { name: 't', inputSchema: { type: 'object' } }; + expect(Wire2025ToolSchema.safeParse({ ...base, outputSchema: { type: 'array' } }).success).toBe(false); + expect(Wire2025ToolSchema.safeParse({ ...base, outputSchema: { type: 'object' } }).success).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..b86a10b398 --- /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'; + +// 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 wire schemas are fully self-contained in the era's schema module: +// every per-method schema the registry serves is a FROZEN 2025-11-25 copy so +// the public/neutral layer can evolve (e.g. SEP-2106 widening) without +// changing the 2025 wire-parse contract. The registry serves the FROZEN +// copies, so the by-reference pins target this module. +import { + CallToolRequestSchema, + CallToolResultSchema, + CancelledNotificationSchema, + CancelTaskRequestSchema, + CompleteRequestSchema, + CompleteResultSchema, + CreateMessageRequestSchema, + CreateMessageResultWithToolsSchema, + ElicitationCompleteNotificationSchema, + ElicitRequestSchema, + ElicitResultSchema, + EmptyResultSchema, + GetPromptRequestSchema, + GetPromptResultSchema, + GetTaskPayloadRequestSchema, + GetTaskRequestSchema, + InitializedNotificationSchema, + InitializeRequestSchema, + InitializeResultSchema, + ListPromptsRequestSchema, + ListPromptsResultSchema, + ListResourcesRequestSchema, + ListResourcesResultSchema, + ListResourceTemplatesRequestSchema, + ListResourceTemplatesResultSchema, + ListRootsRequestSchema, + ListRootsResultSchema, + ListTasksRequestSchema, + ListToolsRequestSchema, + ListToolsResultSchema, + LoggingMessageNotificationSchema, + PingRequestSchema, + ProgressNotificationSchema, + PromptListChangedNotificationSchema, + ReadResourceRequestSchema, + ReadResourceResultSchema, + ResourceListChangedNotificationSchema, + ResourceUpdatedNotificationSchema, + RootsListChangedNotificationSchema, + SetLevelRequestSchema, + SubscribeRequestSchema, + TaskStatusNotificationSchema, + ToolListChangedNotificationSchema, + UnsubscribeRequestSchema +} 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..8551b425d8 --- /dev/null +++ b/packages/core/test/types/schemaBoundaryPins.test.ts @@ -0,0 +1,298 @@ +/** + * 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, + ClientCapabilitiesSchema as Wire2026ClientCapabilitiesSchema, + 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/support-2026-07-28.md "Per-era wire codecs". + 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/upgrade-to-v2.md + // "Wire tightening (every era)". + 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 neutral schema minus `tasks`, and every + // member is the SAME schema object as the wire module's frozen + // ClientCapabilitiesSchema, composed by reference. (The wire module is + // self-contained — it no longer composes from the neutral layer; the + // by-reference check is against the frozen local copy.) + 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 frozen wire shape` + ).toBe((Wire2026ClientCapabilitiesSchema.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/validators/validators.test.ts b/packages/core/test/validators/validators.test.ts index 6c543cb058..0ccf5ad9e4 100644 --- a/packages/core/test/validators/validators.test.ts +++ b/packages/core/test/validators/validators.test.ts @@ -9,7 +9,7 @@ import path from 'node:path'; import { vi } from 'vitest'; -import { AjvJsonSchemaValidator } from '../../src/validators/ajvProvider.js'; +import { Ajv, AjvJsonSchemaValidator } from '../../src/validators/ajvProvider.js'; import { CfWorkerJsonSchemaValidator } from '../../src/validators/cfWorkerProvider.js'; import type { JsonSchemaType } from '../../src/validators/types.js'; @@ -623,3 +623,61 @@ describe('Missing dependencies', () => { }); }); }); + +/** + * SEP-1613 declares JSON Schema 2020-12 the dialect for tool schemas. The built-in providers + * validate as 2020-12 only: a schema with no `$schema` (or `$schema: …2020-12…`) compiles; a + * schema declaring any other `$schema` is rejected with a clear `Error`. The escape hatch is + * the existing custom-engine constructor (caller-supplied Ajv instance / explicit `{draft}`). + * + * Discriminator: `prefixItems` is a 2020-12 keyword that the draft-07 Ajv class silently + * ignores under `strict:false`, so it proves the default engine is `Ajv2020`. + */ +describe('SEP-1613 $schema dialect handling (2020-12 only)', () => { + const DRAFT_07_URI = 'http://json-schema.org/draft-07/schema#'; + const DRAFT_2020_URI = 'https://json-schema.org/draft/2020-12/schema'; + const prefixItemsSchema = ($schema?: string): JsonSchemaType => ({ + ...($schema ? { $schema } : {}), + type: 'array', + prefixItems: [{ type: 'number' }, { type: 'string' }] + }); + /** Violates `prefixItems` (positions swapped). */ + const PREFIX_ITEMS_BAD: unknown = ['x', 1]; + + describe.each(validators)('$name', ({ provider }) => { + it('default → Ajv2020 / 2020-12 (prefixItems is enforced)', () => { + const v = provider.getValidator(prefixItemsSchema()); + expect(v(PREFIX_ITEMS_BAD).valid).toBe(false); + expect(v([1, 'x']).valid).toBe(true); + }); + + it('$schema: 2020-12 → compiles, prefixItems enforced', () => { + const v = provider.getValidator(prefixItemsSchema(DRAFT_2020_URI)); + expect(v(PREFIX_ITEMS_BAD).valid).toBe(false); + }); + + it('$schema: draft-07 → graceful Error', () => { + expect(() => provider.getValidator(prefixItemsSchema(DRAFT_07_URI))).toThrow(/unsupported dialect.*2020-12 only/); + }); + + it('$schema: 2019-09 → graceful Error', () => { + expect(() => provider.getValidator(prefixItemsSchema('https://json-schema.org/draft/2019-09/schema'))).toThrow( + /unsupported dialect/ + ); + }); + }); + + it('AJV: custom Ajv instance bypasses the $schema check (caller owns dialect)', () => { + // A draft-07 Ajv passed explicitly: even with `$schema: draft-07`, the provider does not + // throw — and `prefixItems` is unknown to draft-07 Ajv and silently ignored. + const draft07 = new Ajv({ strict: false, validateSchema: false, allErrors: true }); + const custom = new AjvJsonSchemaValidator(draft07); + const v = custom.getValidator(prefixItemsSchema(DRAFT_07_URI)); + expect(v(PREFIX_ITEMS_BAD).valid).toBe(true); + }); + + it('CfWorker: explicit {draft} bypasses the $schema check (caller owns dialect)', () => { + const custom = new CfWorkerJsonSchemaValidator({ draft: '7' }); + expect(() => custom.getValidator(prefixItemsSchema(DRAFT_07_URI))).not.toThrow(); + }); +}); diff --git a/packages/core/test/wire/codec.test.ts b/packages/core/test/wire/codec.test.ts new file mode 100644 index 0000000000..7238b37d8d --- /dev/null +++ b/packages/core/test/wire/codec.test.ts @@ -0,0 +1,44 @@ +/** + * `codecForVersion` era resolution: the era predicate, not an exact-match + * literal. A pinned modern revision other than '2026-07-28' (e.g. a + * `protocolVersionPin: '2026-09-01'`, or the first entry of a custom modern + * supported-versions list) must resolve to the 2026-era codec — the probe + * builder calls `codecForVersion(pin).outboundEnvelope(…)`, and an exact-match + * resolver would silently return the 2025 codec (whose `outboundEnvelope` is + * `undefined`), producing a probe with no `_meta` envelope and a silent + * downgrade to the legacy connect path. + */ +import { describe, expect, test } from 'vitest'; + +import { codecForVersion, MODERN_WIRE_REVISION } from '../../src/wire/codec.js'; + +const MATERIAL = { + protocolVersion: '2026-09-01', + clientInfo: { name: 'probe-client', version: '0.0.0' }, + clientCapabilities: {} +}; + +describe('codecForVersion era resolution', () => { + test('every modern revision (>= 2026-07-28) resolves to the 2026-era codec', () => { + expect(codecForVersion(MODERN_WIRE_REVISION).era).toBe('2026-07-28'); + expect(codecForVersion('2026-09-01').era).toBe('2026-07-28'); + expect(codecForVersion('2027-01-01').era).toBe('2026-07-28'); + }); + + test('a pinned modern revision other than the literal still produces the 3-key envelope (probe regression)', () => { + const envelope = codecForVersion('2026-09-01').outboundEnvelope(MATERIAL); + expect(envelope).toBeDefined(); + expect(Object.keys(envelope ?? {}).sort()).toEqual([ + 'io.modelcontextprotocol/clientCapabilities', + 'io.modelcontextprotocol/clientInfo', + 'io.modelcontextprotocol/protocolVersion' + ]); + }); + + test('every legacy revision and undefined resolve to the 2025-era codec', () => { + for (const v of ['2024-10-07', '2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25', undefined]) { + expect(codecForVersion(v).era).toBe('2025-11-25'); + expect(codecForVersion(v).outboundEnvelope(MATERIAL)).toBeUndefined(); + } + }); +}); diff --git a/packages/core/test/wire/encodeContract.test.ts b/packages/core/test/wire/encodeContract.test.ts new file mode 100644 index 0000000000..984ec208e9 --- /dev/null +++ b/packages/core/test/wire/encodeContract.test.ts @@ -0,0 +1,274 @@ +/** + * 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 { rev2025Codec } from '../../src/wire/rev2025-11-25/codec.js'; +import { rev2026Codec } from '../../src/wire/rev2026-07-28/codec.js'; +import { DiscoverResultSchema as Wire2026DiscoverResultSchema } from '../../src/wire/rev2026-07-28/schemas.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); + }); +}); + +describe('inbound receiver-side defaults (the parse-side leniency that lets the probe classifier route through the codec)', () => { + const minimalDiscover = { + supportedVersions: ['2026-07-28'], + capabilities: {}, + serverInfo: { name: 's', version: '1' } + }; + + test("validateResult('server/discover', …) fills ttlMs/cacheScope when absent", () => { + const outcome = rev2026Codec.validateResult('server/discover', minimalDiscover); + expect(outcome.ok).toBe(true); + if (!outcome.ok) throw new Error('unreachable'); + expect(outcome.value.ttlMs).toBe(0); + expect(outcome.value.cacheScope).toBe('private'); + }); + + test("the wire-true DiscoverResultSchema fills resultType: 'complete' when absent", () => { + // Schema-level receiver leniency (spec schema.ts:208). `decodeResult` + // step 1 stays strict per Q1-SD3(i) — this defaults the wire-true Zod + // parse only. + const parsed = Wire2026DiscoverResultSchema.parse(minimalDiscover); + expect(parsed.resultType).toBe('complete'); + expect(parsed.ttlMs).toBe(0); + expect(parsed.cacheScope).toBe('private'); + }); + + test('present-but-invalid cache hints (negative ttlMs, unknown cacheScope) fall back to defaults per spec receiver leniency', () => { + // caching.mdx:58 — "if ttlMs is negative, clients SHOULD ignore it and + // treat it as 0". `.catch()` covers both absence and malformed values. + const outcome = rev2026Codec.validateResult('server/discover', { + ...minimalDiscover, + ttlMs: -1, + cacheScope: 'session' + }); + expect(outcome.ok).toBe(true); + if (!outcome.ok) throw new Error('unreachable'); + expect(outcome.value.ttlMs).toBe(0); + expect(outcome.value.cacheScope).toBe('private'); + // The wire-true schema applies the same `.catch()` leniency. + const parsed = Wire2026DiscoverResultSchema.parse({ ...minimalDiscover, ttlMs: -1, cacheScope: 'session' }); + expect(parsed.ttlMs).toBe(0); + expect(parsed.cacheScope).toBe('private'); + }); + + test('explicit values still win over the defaults', () => { + const outcome = rev2026Codec.validateResult('server/discover', { + ...minimalDiscover, + ttlMs: 30_000, + cacheScope: 'public' + }); + expect(outcome.ok).toBe(true); + if (!outcome.ok) throw new Error('unreachable'); + expect(outcome.value.ttlMs).toBe(30_000); + expect(outcome.value.cacheScope).toBe('public'); + }); +}); + +describe('the error half of the encode seam — encodeErrorCode', () => { + test('the -32002 resource-not-found domain code maps to -32602 on BOTH eras (flat; no era branch preserves -32002)', () => { + // The seam owns wire-code selection; both era codecs select -32602. + expect(rev2026Codec.encodeErrorCode(-32_002)).toBe(-32_602); + expect(rev2025Codec.encodeErrorCode(-32_002)).toBe(-32_602); + }); + + test('every other code passes through identically on both eras', () => { + for (const code of [-32_700, -32_600, -32_601, -32_602, -32_603, -32_000, -32_020, -32_021, -32_022, -32_042, -1, 0]) { + expect(rev2026Codec.encodeErrorCode(code)).toBe(code); + expect(rev2025Codec.encodeErrorCode(code)).toBe(code); + } + }); +}); diff --git a/packages/core/test/wire/eraGates.test.ts b/packages/core/test/wire/eraGates.test.ts new file mode 100644 index 0000000000..dac1ae70c9 --- /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 −32022 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 −32022, 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(-32022); + 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: -32022 }); + 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(-32022); + 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/wire/rev2025-11-25/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/layeringInvariants.test.ts b/packages/core/test/wire/layeringInvariants.test.ts new file mode 100644 index 0000000000..39f767d748 --- /dev/null +++ b/packages/core/test/wire/layeringInvariants.test.ts @@ -0,0 +1,119 @@ +/** + * Wire-layer / public-layer import isolation, enforced as a test. + * + * eslint.config.mjs carries the matching `@typescript-eslint/no-restricted-imports` + * rules; this suite re-derives the same invariants directly from source so the + * lint rules cannot be silently weakened, scoped away, or `eslint-disable`d. + * + * Invariants: + * (a) No file outside src/wire/ imports from a wire/rev… module. No exceptions. + * (b) No file inside a src/wire/rev… directory has a runtime (non-type-only) + * import from types/schemas — wire revision schemas are frozen copies. + * (c) No `eslint-disable` directive for `no-restricted-imports` appears in + * any file covered by (a) or (b). + */ +import { readdirSync, readFileSync } from 'node:fs'; +import { posix, sep } from 'node:path'; + +import { describe, expect, expectTypeOf, test } from 'vitest'; + +import type { WireCodec } from '../../src/wire/codec.js'; + +const SRC_ROOT = new URL('../../src/', import.meta.url); + +function listSourceFiles(): string[] { + // Recursive walk of src/ for .ts files (excluding .d.ts). Paths returned + // posix-normalised relative to src/ so assertions are stable across OSes. + const out: string[] = []; + const walk = (rel: string) => { + for (const entry of readdirSync(new URL(rel, SRC_ROOT), { withFileTypes: true })) { + const child = rel + entry.name; + if (entry.isDirectory()) { + walk(child + '/'); + } else if (entry.isFile() && child.endsWith('.ts') && !child.endsWith('.d.ts')) { + out.push(child.split(sep).join(posix.sep)); + } + } + }; + walk(''); + return out.sort(); +} + +function read(rel: string): string { + return readFileSync(new URL(rel, SRC_ROOT), 'utf8'); +} + +/** Matches any import/re-export whose module specifier contains `wire/rev`. */ +const WIRE_REV_IMPORT = /^(import|export)\b[^;]*?\bfrom\s+['"][^'"]*wire\/rev[^'"]*['"]/m; + +/** Matches a runtime (non-type-only) import or re-export of the public schemas module. */ +const RUNTIME_SCHEMAS_IMPORT = /^(import|export)\s+(?!type\b)[^;]*?\bfrom\s+['"][^'"]*types\/schemas(?:\.js)?['"]/m; + +/** Matches an eslint-disable directive that touches a no-restricted-imports rule. */ +const DISABLE_RESTRICTED = /eslint-disable[^\n]*no-restricted-imports/; + +describe('wire-layer / public-layer import isolation', () => { + const allFiles = listSourceFiles(); + const outsideWire = allFiles.filter(f => !f.startsWith('wire/')); + const insideWireRev = allFiles.filter(f => /^wire\/rev[^/]+\//.test(f)); + + test('(a) no file outside src/wire/ imports from a wire/rev* module', () => { + const offenders = outsideWire.filter(f => WIRE_REV_IMPORT.test(read(f))); + expect(offenders).toEqual([]); + }); + + test('(b) no runtime import of types/schemas inside src/wire/rev*/', () => { + const offenders = insideWireRev.filter(f => RUNTIME_SCHEMAS_IMPORT.test(read(f))); + expect(offenders).toEqual([]); + }); + + test('(c) no eslint-disable of no-restricted-imports in covered files', () => { + const covered = [...outsideWire, ...insideWireRev]; + const offenders = covered.filter(f => DISABLE_RESTRICTED.test(read(f))); + expect(offenders).toEqual([]); + }); + + test('sanity: walk found both partitions', () => { + expect(outsideWire.length).toBeGreaterThan(0); + expect(insideWireRev.length).toBeGreaterThan(0); + }); +}); + +describe('WireCodec interface is function-only (no schema getters)', () => { + // The pre-separation interface exposed per-method Zod schema getters + // (`requestSchema(m)`, `resultSchema(m)`, …). Those were the leak that let + // callers reach raw validators across the layer boundary. Re-adding ANY of + // them must fail this suite even if the return type is widened to `unknown`. + const FORBIDDEN = ['requestSchema', 'resultSchema', 'notificationSchema', 'inputRequestSchema', 'inputResponseSchema'] as const; + + test('(d) type-level: forbidden identifiers are not keys of WireCodec', () => { + type Forbidden = (typeof FORBIDDEN)[number]; + // If any forbidden name re-enters `keyof WireCodec`, the intersection + // becomes that literal (≠ never) and this assertion fails to compile. + expectTypeOf>().toEqualTypeOf(); + }); + + test('(d) source-level: codec.ts WireCodec body declares no *Schema members', () => { + const src = readFileSync(new URL('wire/codec.ts', SRC_ROOT), 'utf8'); + const open = src.indexOf('export interface WireCodec'); + expect(open).toBeGreaterThan(-1); + // Find the matching closing brace of the interface body. + let depth = 0; + let close = -1; + for (let i = src.indexOf('{', open); i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}' && --depth === 0) { + close = i; + break; + } + } + expect(close).toBeGreaterThan(open); + const body = src.slice(open, close); + for (const name of FORBIDDEN) { + // Match `name(` or `name:` or `name?` at a member-declaration + // position (start-of-line modulo whitespace / `readonly`). + const re = new RegExp(String.raw`^\s*(?:readonly\s+)?${name}\s*[(:?]`, 'm'); + expect(body).not.toMatch(re); + } + }); +}); diff --git a/packages/core/test/wire/legacyWrap.test.ts b/packages/core/test/wire/legacyWrap.test.ts new file mode 100644 index 0000000000..ed421c1210 --- /dev/null +++ b/packages/core/test/wire/legacyWrap.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest'; + +import { wrapOutputSchemaForLegacy } from '../../src/wire/rev2025-11-25/legacyWrap.js'; +import { rev2025Codec } from '../../src/wire/rev2025-11-25/codec.js'; + +/** Test helper: drill into a nested untyped object by path. */ +function dig(node: unknown, ...path: ReadonlyArray): unknown { + let cur: unknown = node; + for (const k of path) cur = (cur as Record)[k]; + return cur; +} + +describe('wrapOutputSchemaForLegacy: position-aware $ref rewrite', () => { + it('rewrites $ref/$dynamicRef in keyword position; leaves data positions (const/enum/default/examples) untouched', () => { + const wrapped = wrapOutputSchemaForLegacy({ + anyOf: [{ $dynamicRef: '#/$defs/X' }, { const: { $ref: '#/foo' } }], + $defs: { + X: { + type: 'object', + properties: { v: { type: 'number', default: { $ref: '#' }, examples: [{ $ref: '#/bar' }] } }, + required: ['v'] + } + } + }); + expect(wrapped).toEqual({ + type: 'object', + properties: { + result: { + anyOf: [{ $dynamicRef: '#/properties/result/$defs/X' }, { const: { $ref: '#/foo' } }], + $defs: { + X: { + type: 'object', + properties: { + v: { type: 'number', default: { $ref: '#' }, examples: [{ $ref: '#/bar' }] } + }, + required: ['v'] + } + } + } + }, + required: ['result'] + }); + }); + + it('a property NAMED default/const under properties/$defs is a name position — its value IS a subschema and is recursed into', () => { + // `properties.default` and `$defs.const` are author-chosen NAMES that + // collide with JSON Schema keywords. Their values are subschemas in + // keyword position, so a `$ref` inside is rewritten. + const wrapped = wrapOutputSchemaForLegacy({ + type: 'array', + items: { + type: 'object', + properties: { + default: { $ref: '#/$defs/const' }, + const: { $ref: '#' } + } + }, + $defs: { const: { type: 'string' } } + }); + expect(dig(wrapped, 'properties', 'result', 'items', 'properties', 'default')).toEqual({ + $ref: '#/properties/result/$defs/const' + }); + expect(dig(wrapped, 'properties', 'result', 'items', 'properties', 'const')).toEqual({ $ref: '#/properties/result' }); + // `$defs.const` is a name position (its value is a subschema), so recursion descends — but + // there's no `$ref` inside `{type:'string'}`; the entry itself is kept. + expect(dig(wrapped, 'properties', 'result', '$defs', 'const')).toEqual({ type: 'string' }); + }); + + it('patternProperties / dependentSchemas / definitions are name maps too', () => { + const wrapped = wrapOutputSchemaForLegacy({ + anyOf: [{ $ref: '#/$defs/X' }], + patternProperties: { '^default$': { $ref: '#' } }, + dependentSchemas: { enum: { $ref: '#/$defs/X' } }, + definitions: { examples: { $ref: '#' } }, + $defs: { X: { type: 'number' } } + }); + expect(dig(wrapped, 'properties', 'result', 'patternProperties')).toEqual({ + '^default$': { $ref: '#/properties/result' } + }); + expect(dig(wrapped, 'properties', 'result', 'dependentSchemas')).toEqual({ + enum: { $ref: '#/properties/result/$defs/X' } + }); + expect(dig(wrapped, 'properties', 'result', 'definitions')).toEqual({ examples: { $ref: '#/properties/result' } }); + }); +}); + +describe('wrapOutputSchemaForLegacy: $id-scoped rewrite', () => { + it('a natural root with $id skips the pointer rewrite entirely (refs resolve against the embedded base)', () => { + const natural = { + $id: 'https://x', + type: 'array', + items: { $ref: '#/$defs/D' }, + $defs: { D: { type: 'number' } } + } as const; + const wrapped = wrapOutputSchemaForLegacy(natural); + // Wrapped, but the embedded schema is referentially identical — NO ref was rewritten. + expect(wrapped).toEqual({ type: 'object', properties: { result: natural }, required: ['result'] }); + expect(dig(wrapped, 'properties', 'result')).toBe(natural); + }); + + it('a nested subtree establishing its own $id is left untouched; the rest of the schema is still rewritten', () => { + const sub = { $id: 'https://y', items: { $ref: '#/$defs/E' }, $defs: { E: { type: 'string' } } }; + const wrapped = wrapOutputSchemaForLegacy({ + anyOf: [{ $ref: '#/$defs/D' }, sub], + $defs: { D: { type: 'number' } } + }); + // First anyOf member rewritten; the $id-carrying member is left untouched (referentially). + expect(dig(wrapped, 'properties', 'result', 'anyOf', 0)).toEqual({ $ref: '#/properties/result/$defs/D' }); + expect(dig(wrapped, 'properties', 'result', 'anyOf', 1)).toBe(sub); + }); + + it('a root $schema is hoisted to the wrapper root (so the SEP-1613 dialect check still fires on the projection)', () => { + const wrapped = wrapOutputSchemaForLegacy({ + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'array', + items: { type: 'number' } + }); + expect(wrapped['$schema']).toBe('http://json-schema.org/draft-07/schema#'); + // The natural copy under properties.result also still carries it (harmless in subschema position). + expect(dig(wrapped, 'properties', 'result', '$schema')).toBe('http://json-schema.org/draft-07/schema#'); + }); + + it('a property NAMED $id under a name map does NOT establish a resolution base', () => { + // `properties.$id` is a name-position entry whose value is a subschema; the + // `$id` key here is a property name, not the keyword, so the surrounding + // subtree is still rewritten. + const wrapped = wrapOutputSchemaForLegacy({ + type: 'array', + items: { type: 'object', properties: { $id: { type: 'string' } }, allOf: [{ $ref: '#' }] } + }); + expect(dig(wrapped, 'properties', 'result', 'items', 'allOf')).toEqual([{ $ref: '#/properties/result' }]); + }); +}); + +describe('rev2025Codec.projectCallToolResult: value-shape wrap', () => { + it('wraps a non-object structuredContent value as {result:…} when no outputSchema is advertised', () => { + const out = rev2025Codec.projectCallToolResult({ content: [], structuredContent: [1, 2, 3] }, undefined); + expect(out.structuredContent).toEqual({ result: [1, 2, 3] }); + }); + + it.each([0, false, '', null] as const)('wraps falsy non-object value %p (presence is !== undefined)', sc => { + const out = rev2025Codec.projectCallToolResult({ content: [], structuredContent: sc }, undefined); + expect(out.structuredContent).toEqual({ result: sc }); + }); + + it('leaves object-shaped structuredContent unwrapped when no schema is advertised (already wire-legal)', () => { + const out = rev2025Codec.projectCallToolResult({ content: [], structuredContent: { a: 1 } }, undefined); + expect(out.structuredContent).toEqual({ a: 1 }); + }); + + it('still wraps an object-shaped value when the advertised schema has a non-object root (schema/result coherence)', () => { + const out = rev2025Codec.projectCallToolResult( + { content: [], structuredContent: { a: 1 } }, + { anyOf: [{ type: 'object' }, { type: 'string' }] } + ); + expect(out.structuredContent).toEqual({ result: { a: 1 } }); + }); +}); 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..6bdd2937c8 --- /dev/null +++ b/packages/core/test/wire/registryDiffOracle.test.ts @@ -0,0 +1,97 @@ +/** + * 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). + */ +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' + } +}; + +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/rev2025FrozenShapes.test.ts b/packages/core/test/wire/rev2025FrozenShapes.test.ts new file mode 100644 index 0000000000..c9178aa861 --- /dev/null +++ b/packages/core/test/wire/rev2025FrozenShapes.test.ts @@ -0,0 +1,46 @@ +/** + * Q10-L2 byte-identity pins for the frozen 2025-11-25 wire shapes that were + * decoupled from `types/schemas.ts` so the public/neutral schema layer can + * evolve (SEP-2106 widening) without changing the 2025 wire-parse contract. + * Each pin proves the FROZEN copy still rejects the SEP-2106 vocabulary on + * the 2025 wire. + */ +import { describe, expect, it } from 'vitest'; + +import { + CallToolResultSchema, + CreateMessageResultWithToolsSchema, + ListToolsResultSchema, + ToolResultContentSchema, + ToolSchema +} from '../../src/wire/rev2025-11-25/schemas.js'; + +describe('frozen 2025-11-25 wire shapes (Q10-L2)', () => { + it('CallToolResultSchema rejects non-object structuredContent', () => { + expect(CallToolResultSchema.safeParse({ content: [], structuredContent: [1, 2, 3] }).success).toBe(false); + expect(CallToolResultSchema.safeParse({ content: [], structuredContent: 0 }).success).toBe(false); + expect(CallToolResultSchema.safeParse({ content: [], structuredContent: { result: [1] } }).success).toBe(true); + }); + + it("ToolSchema rejects non-type:'object' outputSchema", () => { + const base = { name: 't', inputSchema: { type: 'object' } }; + expect(ToolSchema.safeParse({ ...base, outputSchema: { type: 'array' } }).success).toBe(false); + expect(ToolSchema.safeParse({ ...base, outputSchema: { type: 'object' } }).success).toBe(true); + }); + + it('ListToolsResultSchema composes the frozen ToolSchema', () => { + const arr = { tools: [{ name: 't', inputSchema: { type: 'object' }, outputSchema: { type: 'array' } }] }; + expect(ListToolsResultSchema.safeParse(arr).success).toBe(false); + }); + + it('ToolResultContentSchema rejects non-object structuredContent', () => { + const base = { type: 'tool_result', toolUseId: 'x', content: [] }; + expect(ToolResultContentSchema.safeParse({ ...base, structuredContent: [1] }).success).toBe(false); + expect(ToolResultContentSchema.safeParse({ ...base, structuredContent: { ok: true } }).success).toBe(true); + }); + + it('CreateMessageResultWithToolsSchema composes the frozen tool_result arm', () => { + const tr = { type: 'tool_result', toolUseId: 'x', content: [], structuredContent: [1] }; + expect(CreateMessageResultWithToolsSchema.safeParse({ model: 'm', role: 'assistant', content: [tr] }).success).toBe(false); + }); +}); 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/README.md b/packages/middleware/node/README.md index fe10c9f2ae..15a8e8b9c1 100644 --- a/packages/middleware/node/README.md +++ b/packages/middleware/node/README.md @@ -16,6 +16,9 @@ npm install @modelcontextprotocol/server @modelcontextprotocol/node - `NodeStreamableHTTPServerTransport` - `StreamableHTTPServerTransportOptions` (type alias for `WebStandardStreamableHTTPServerTransportOptions`) +- `toNodeHandler(handler, opts?)` — adapt a web-standard `{ fetch }` MCP handler to a Node `(req, res, parsedBody?)` handler +- `ToNodeHandlerOptions`, `FetchLikeMcpHandler`, `NodeMcpRequestHandler` (types for `toNodeHandler`) +- `NodeIncomingMessageLike`, `NodeServerResponseLike` (structural Node request/response shapes) ## Usage diff --git a/packages/middleware/node/src/index.ts b/packages/middleware/node/src/index.ts index 2e0d3c9950..9cff39c82c 100644 --- a/packages/middleware/node/src/index.ts +++ b/packages/middleware/node/src/index.ts @@ -1 +1,11 @@ +export * from './middleware/hostHeaderValidation.js'; +export * from './middleware/originValidation.js'; export * from './streamableHttp.js'; +export type { + FetchLikeMcpHandler, + NodeIncomingMessageLike, + NodeMcpRequestHandler, + NodeServerResponseLike, + ToNodeHandlerOptions +} from './toNodeHandler.js'; +export { toNodeHandler } from './toNodeHandler.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 c101fcd0af..a6f1c43a6b 100644 --- a/packages/middleware/node/src/streamableHttp.ts +++ b/packages/middleware/node/src/streamableHttp.ts @@ -158,6 +158,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/src/toNodeHandler.ts b/packages/middleware/node/src/toNodeHandler.ts new file mode 100644 index 0000000000..ed345492ec --- /dev/null +++ b/packages/middleware/node/src/toNodeHandler.ts @@ -0,0 +1,271 @@ +/** + * `toNodeHandler` — adapt the web-standard {@linkcode McpHttpHandler} returned + * by `createMcpHandler` to a Node.js `(req, res, parsedBody?)` request handler. + * + * The handler itself is web-standards-only (`{ fetch, close, notify, bus }` — the + * shape Workers/Bun/Deno expect from `export default`). Node frameworks + * (Express, Fastify, plain `node:http`) wrap it once with this helper: + * + * ```ts + * import { createMcpHandler } from '@modelcontextprotocol/server'; + * import { toNodeHandler } from '@modelcontextprotocol/node'; + * + * const handler = createMcpHandler(factory); + * app.all('/mcp', toNodeHandler(handler)); + * // or, when a body parser already consumed the stream: + * const node = toNodeHandler(handler); + * app.all('/mcp', (req, res) => void node(req, res, req.body)); + * ``` + * + * The Node request/response shapes are duck-typed (kept structural so this + * module stays free of `node:` imports); the conversion reads `req.auth` + * (validated authentication info attached by upstream middleware) and forwards + * it as the handler's pass-through `authInfo`. + */ +import type { AuthInfo, McpHandlerRequestOptions } from '@modelcontextprotocol/server'; + +/** + * Minimal duck-typed shape of a Node.js `IncomingMessage` accepted by + * {@linkcode toNodeHandler}. Kept structural so the adapter stays free of + * `node:` imports. + */ +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 toNodeHandler}. */ +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; + destroyed?: boolean; +} + +/** + * The web-standard fetch face of an `McpHttpHandler` (or any + * fetch-shaped MCP handler) — the only surface {@linkcode toNodeHandler} + * touches. Accepting the face structurally keeps the adapter usable with + * hand-wired compositions that route over `isLegacyRequest` and produce a + * `Response` directly. + */ +export interface FetchLikeMcpHandler { + fetch: (request: Request, options?: McpHandlerRequestOptions) => Promise; +} + +/** + * A Node.js `(req, res, parsedBody?)` request handler produced by + * {@linkcode toNodeHandler}. 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. + */ +export type NodeMcpRequestHandler = (req: NodeIncomingMessageLike, res: NodeServerResponseLike, parsedBody?: unknown) => Promise; + +/** Options for {@linkcode toNodeHandler}. */ +export interface ToNodeHandlerOptions { + /** + * Called when the adapter answers `500` because request conversion or + * `handler.fetch` itself threw (e.g. a closed handler). Restores the + * observability the removed `.node` face had via the entry's own + * `onerror` — entry-internal failures are still reported through + * `handler.fetch` and surface via the entry's `onerror` option as before. + */ + onerror?: (error: Error) => void; +} + +/** + * Adapts a web-standard MCP handler (`handler.fetch`) to a Node.js + * `(req, res, parsedBody?)` request handler. The returned function converts the + * Node request to a web-standard `Request`, calls `handler.fetch`, then writes + * the `Response` back to `res` (honoring write backpressure for streamed SSE + * responses). + * + * `req.auth` is forwarded as the handler's pass-through `authInfo`. A function + * third argument (Express's `next`) is ignored, never treated as a body. + * + * Pass `{ onerror }` to observe the adapter-level error fallback (request + * conversion / `handler.fetch` throw) before the `500` response is written. + */ +export function toNodeHandler(handler: FetchLikeMcpHandler, opts?: ToNodeHandlerOptions): NodeMcpRequestHandler { + return async (req, res, parsedBody) => { + // 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(); + } + }); + if (res.destroyed === true) { + abort.abort(); + } + + let response: Response; + try { + const request = await nodeRequestToFetchRequest(req, parsedBody, abort.signal); + response = await handler.fetch(request, { + ...(req.auth !== undefined && { authInfo: req.auth }), + ...(parsedBody !== undefined && { parsedBody }) + }); + } catch (error) { + try { + opts?.onerror?.(error instanceof Error ? error : new Error(String(error))); + } catch { + // Reporting must never alter the response. + } + 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; + } + // Honor write backpressure: when write() reports a full buffer (Node's + // `false` return), wait for the response to drain before pulling the + // next chunk. The abort signal (wired to 'close' above, and seeded + // from `res.destroyed` at entry to cover the pre-registration window + // when 'close' already fired during async middleware) is the single + // termination source — racing it against the drain wait means a + // vanished client cannot park the loop, and breaking out of the async + // iterator calls return() to cancel the upstream stream. + let drainResolve: (() => void) | undefined; + const releaseDrainWait = () => { + drainResolve?.(); + drainResolve = undefined; + }; + res.on('drain', releaseDrainWait); + const closed = new Promise(resolve => { + abort.signal.addEventListener('abort', () => resolve(), { once: true }); + }); + try { + for await (const chunk of response.body) { + if (abort.signal.aborted) { + break; + } + if (res.write(chunk) === false) { + await Promise.race([ + new Promise(resolve => { + drainResolve = resolve; + }), + closed + ]); + } + } + } catch { + // Stream aborted upstream; the abort signal already cancelled the exchange. + } + finished = true; + res.end(); + }; +} + +/* ------------------------------------------------------------------------ * + * 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 `(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 — anything + // on the legacy leg reading `request.json()`/`text()` instead of + // the pass-through parsedBody — 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 }) + }); +} + +/* ------------------------------------------------------------------------ * + * Adapter-level error fallback (request conversion failure / closed handler) + * ------------------------------------------------------------------------ */ + +/** + * The JSON-RPC id to echo on an adapter-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. + */ +function echoableRequestId(body: unknown): string | number | 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 internalServerErrorResponse(id: string | number | null): Response { + return Response.json({ jsonrpc: '2.0', error: { code: -32_603, message: 'Internal server error' }, id }, { status: 500 }); +} diff --git a/packages/middleware/node/test/toNodeHandler.test.ts b/packages/middleware/node/test/toNodeHandler.test.ts new file mode 100644 index 0000000000..f80d96b2b8 --- /dev/null +++ b/packages/middleware/node/test/toNodeHandler.test.ts @@ -0,0 +1,416 @@ +/** + * `toNodeHandler(handler)` — the Node `(req, res, parsedBody?)` adapter over a + * web-standard `McpHttpHandler`. Covers the request-stream conversion, the + * pre-parsed-body path (the documented `express.json()` mounting), `req.auth` + * pass-through, HTTP/2 pseudo-header skipping, and write-backpressure pacing. + * + * These tests previously lived in `@modelcontextprotocol/server`'s + * `createMcpHandler.test.ts` as the `.node` face tests; the body of the + * adapter is unchanged, only its home moved. + */ +import { Readable } from 'node:stream'; + +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { describe, expect, it, vi } from 'vitest'; +import * as z from 'zod/v4'; + +import type { NodeServerResponseLike } from '../src/toNodeHandler.js'; +import { toNodeHandler } from '../src/toNodeHandler.js'; + +const MODERN_REVISION = '2026-07-28'; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'node-adapter-test-client', version: '3.2.1' }, + [CLIENT_CAPABILITIES_META_KEY]: { elicitation: { form: {} } } +}; + +function modernToolsCall(name: string, args: Record): unknown { + return { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name, arguments: args, _meta: ENVELOPE } + }; +} + +/** SEP-2243 standard headers a conformant client derives from a modern body. */ +function bodyDerivedStandardHeaders(body: unknown): Record { + if (body === null || typeof body !== 'object' || Array.isArray(body)) return {}; + const b = body as { method?: unknown; params?: { name?: unknown; uri?: unknown; _meta?: Record } }; + if (typeof b.params?._meta?.[PROTOCOL_VERSION_META_KEY] !== 'string') return {}; + const out: Record = {}; + if (typeof b.method === 'string') out['mcp-method'] = b.method; + const name = b.method === 'resources/read' ? b.params.uri : b.params.name; + if (typeof name === 'string') out['mcp-name'] = name; + return out; +} + +function testFactory(): { factory: (ctx: McpRequestContext) => McpServer; contexts: McpRequestContext[] } { + const contexts: McpRequestContext[] = []; + const factory = (ctx: McpRequestContext): McpServer => { + contexts.push(ctx); + const mcpServer = new McpServer({ name: 'node-adapter-test-server', version: '1.0.0' }); + 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 }] }; + }); + return mcpServer; + }; + return { factory, contexts }; +} + +describe('toNodeHandler', () => { + it('serves through the duck-typed Node adapter, reading the request stream when no parsed body is given', async () => { + const { factory } = testFactory(); + const node = toNodeHandler(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 node(req, res, () => {}); + expect(res.statusCode).toBe(200); + expect(await body()).toContain('node face'); + }); + + it('prefers a pre-parsed body over the request stream', async () => { + const { factory } = testFactory(); + const node = toNodeHandler(createMcpHandler(factory)); + + const parsed = modernToolsCall('echo', { text: 'pre-parsed' }); + const { req, res, body } = nodeRequestResponse(undefined); + Object.assign(req.headers, bodyDerivedStandardHeaders(parsed)); + await node(req, res, parsed); + expect(res.statusCode).toBe(200); + expect(await body()).toContain('pre-parsed'); + }); + + it('serves a pre-parsed legacy body on the default fallback (the documented express.json mounting)', async () => { + const { factory, contexts } = testFactory(); + const node = toNodeHandler(createMcpHandler(factory)); + + // 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 legacyMessage = { jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'echo', arguments: { text: 'node legacy' } } }; + const { req, res, body } = nodeRequestResponse(undefined); + req.headers['content-length'] = '999'; + req.headers['transfer-encoding'] = 'chunked'; + await node(req, res, legacyMessage); + + expect(res.statusCode).toBe(200); + expect(await body()).toContain('node legacy'); + expect(contexts).toHaveLength(1); + expect(contexts[0]?.era).toBe('legacy'); + }); + + it('forwards req.auth from upstream middleware as pass-through authInfo', async () => { + const { factory } = testFactory(); + const node = toNodeHandler(createMcpHandler(factory)); + + const { req, res, body } = nodeRequestResponse(modernToolsCall('whoami', {})); + req.auth = { token: 'verified', clientId: 'node-client', scopes: [] }; + await 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 node = toNodeHandler(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 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 node = toNodeHandler(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 = 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'); + }); + + it('does not park on backpressure when the client closes without ever draining', async () => { + const { factory } = testFactory(); + const node = toNodeHandler(createMcpHandler(factory)); + + const listeners = new Map void>>(); + let writeCount = 0; + const res: NodeServerResponseLike & { statusCode: number } = { + statusCode: 0, + writeHead(statusCode: number) { + this.statusCode = statusCode; + return this; + }, + write() { + writeCount += 1; + // Always report a full buffer; 'drain' will never fire. + 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 emitClose = () => { + for (const listener of listeners.get('close') ?? []) { + listener(); + } + }; + + const { req } = nodeRequestResponse(modernToolsCall('progress-then-echo', { text: 'gone' })); + const served = node(req, res); + + // The first chunk is written, then the loop waits for drain. + await vi.waitFor(() => expect(writeCount).toBe(1)); + // The client vanishes mid-stream: 'close' fires, 'drain' never does. + emitClose(); + + // The handler promise must resolve — racing the abort signal against + // the drain wait releases the loop instead of parking forever. + await expect( + Promise.race([served, new Promise((_, reject) => setTimeout(() => reject(new Error('parked')), 500))]) + ).resolves.toBeUndefined(); + }); + + it('does not park on backpressure when the response was already destroyed before the adapter listened', async () => { + // 'close' fired during async middleware BEFORE toNodeHandler registered + // its listener — `res.destroyed` is the entry-time witness. The + // adapter must seed the abort from it so the drain wait cannot park. + const { factory } = testFactory(); + const node = toNodeHandler(createMcpHandler(factory)); + + const res: NodeServerResponseLike & { statusCode: number } = { + statusCode: 0, + destroyed: true, + writeHead(statusCode: number) { + this.statusCode = statusCode; + return this; + }, + write() { + // Always report a full buffer; 'drain' will never fire and + // 'close' already happened (no listener will ever be called). + return false; + }, + end() { + return this; + }, + on() { + return this; + } + }; + + const { req } = nodeRequestResponse(modernToolsCall('progress-then-echo', { text: 'gone' })); + const served = node(req, res); + + // The handler promise must resolve — the entry-time `destroyed` check + // seeds the abort signal so the drain race releases immediately. + await expect( + Promise.race([served, new Promise((_, reject) => setTimeout(() => reject(new Error('parked')), 500))]) + ).resolves.toBeUndefined(); + }); + + it('answers with a 500 JSON-RPC error when handler.fetch throws (closed handler)', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + await handler.close(); + const node = toNodeHandler(handler); + + const parsed = modernToolsCall('echo', { text: 'late' }); + const { req, res, body } = nodeRequestResponse(undefined); + Object.assign(req.headers, bodyDerivedStandardHeaders(parsed)); + await node(req, res, parsed); + expect(res.statusCode).toBe(500); + const payload = JSON.parse(await body()) as { error: { code: number }; id: unknown }; + expect(payload.error.code).toBe(-32_603); + expect(payload.id).toBe(1); + }); + + it('reports the adapter-level fallback error to onerror before answering 500', async () => { + const thrown = new Error('fetch boom'); + const onerror = vi.fn(); + const node = toNodeHandler( + { + fetch: () => { + throw thrown; + } + }, + { onerror } + ); + + const parsed = modernToolsCall('echo', { text: 'late' }); + const { req, res, body } = nodeRequestResponse(undefined); + Object.assign(req.headers, bodyDerivedStandardHeaders(parsed)); + await node(req, res, parsed); + + expect(onerror).toHaveBeenCalledTimes(1); + expect(onerror).toHaveBeenCalledWith(thrown); + expect(res.statusCode).toBe(500); + const payload = JSON.parse(await body()) as { error: { code: number }; id: unknown }; + expect(payload.error.code).toBe(-32_603); + expect(payload.id).toBe(1); + }); + + it('still answers 500 when the onerror callback itself throws', async () => { + const node = toNodeHandler( + { + fetch: () => { + throw new Error('fetch boom'); + } + }, + { + onerror: () => { + throw new Error('reporter boom'); + } + } + ); + + const parsed = modernToolsCall('echo', { text: 'late' }); + const { req, res, body } = nodeRequestResponse(undefined); + Object.assign(req.headers, bodyDerivedStandardHeaders(parsed)); + await node(req, res, parsed); + + expect(res.statusCode).toBe(500); + const payload = JSON.parse(await body()) as { error: { code: number }; id: unknown }; + expect(payload.error.code).toBe(-32_603); + expect(payload.id).toBe(1); + }); +}); + +/* ------------------------------------------------------------------------ * + * 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', + ...bodyDerivedStandardHeaders(body) + } 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(''); + } + }; +} 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-legacy/src/auth/handlers/authorize.ts b/packages/server-legacy/src/auth/handlers/authorize.ts index 15dfe3cb90..adbfbfa053 100644 --- a/packages/server-legacy/src/auth/handlers/authorize.ts +++ b/packages/server-legacy/src/auth/handlers/authorize.ts @@ -1,4 +1,4 @@ -import type { RequestHandler } from 'express'; +import type { RequestHandler, Response } from 'express'; import express from 'express'; import type { Options as RateLimitOptions } from 'express-rate-limit'; import { rateLimit } from 'express-rate-limit'; @@ -6,10 +6,19 @@ import * as z from 'zod/v4'; import { InvalidClientError, InvalidRequestError, OAuthError, ServerError, TooManyRequestsError } from '../errors.js'; import { allowedMethods } from '../middleware/allowedMethods.js'; -import type { OAuthServerProvider } from '../provider.js'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- AuthorizationParams referenced in JSDoc {@linkcode} +import type { AuthorizationParams, OAuthServerProvider } from '../provider.js'; export type AuthorizationHandlerOptions = { provider: OAuthServerProvider; + /** + * The authorization server's issuer identifier. When set, the handler appends it as the + * `iss` query parameter (RFC 9207) to any redirect — success or error — that targets the + * client's validated `redirect_uri`, and also supplies it to the provider as + * {@linkcode AuthorizationParams.issuer}. `mcpAuthRouter` always sets this from its + * `issuerUrl`. + */ + issuerUrl?: URL; /** * Rate limiting configuration for the authorization endpoint. * Set to false to disable rate limiting for this endpoint. @@ -69,7 +78,8 @@ const RequestAuthorizationParamsSchema = z.object({ resource: z.string().url().optional() }); -export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler { +export function authorizationHandler({ provider, issuerUrl, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler { + const issuer = issuerUrl?.href; // Create a router to apply middleware const router = express.Router(); router.use(allowedMethods(['GET', 'POST'])); @@ -158,7 +168,11 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A requestedScopes = scope.split(' '); } - // All validation passed, proceed with authorization + // All validation passed, proceed with authorization. RFC 9207: the metadata + // advertises `authorization_response_iss_parameter_supported`, so make that claim + // true from SDK code by appending `iss` to whatever redirect the provider issues + // back to the client's validated redirect_uri — the provider need not do anything. + // Redirects elsewhere (e.g. to an upstream authorize endpoint) are left untouched. await provider.authorize( client, { @@ -166,17 +180,18 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A scopes: requestedScopes, redirectUri: redirect_uri!, codeChallenge: code_challenge, - resource: resource ? new URL(resource) : undefined + resource: resource ? new URL(resource) : undefined, + issuer }, - res + issuer ? withIssOnCallbackRedirect(res, redirect_uri!, issuer) : res ); } catch (error) { // Post-redirect errors - redirect with error parameters if (error instanceof OAuthError) { - res.redirect(302, createErrorRedirect(redirect_uri!, error, state)); + res.redirect(302, createErrorRedirect(redirect_uri!, error, state, issuer)); } else { const serverError = new ServerError('Internal Server Error'); - res.redirect(302, createErrorRedirect(redirect_uri!, serverError, state)); + res.redirect(302, createErrorRedirect(redirect_uri!, serverError, state, issuer)); } } }); @@ -184,10 +199,43 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A return router; } +/** + * Wraps `res.redirect` so that when the provider redirects to the client's validated + * `redirect_uri` (i.e. the OAuth authorization response), `iss` is appended per RFC 9207. + * Only redirects whose origin and path match `redirectUri` are touched; an `iss` already + * set by the provider is preserved. This is what backs the + * `authorization_response_iss_parameter_supported: true` metadata claim without requiring + * `OAuthServerProvider.authorize()` implementations to change. + */ +function withIssOnCallbackRedirect(res: Response, redirectUri: string, issuer: string): Response { + const cb = new URL(redirectUri); + const appendIss = (url: string): string => { + let target: URL; + try { + target = new URL(url); + } catch { + return url; + } + if (target.origin === cb.origin && target.pathname === cb.pathname && !target.searchParams.has('iss')) { + target.searchParams.set('iss', issuer); + return target.href; + } + return url; + }; + const original = res.redirect.bind(res) as (...args: unknown[]) => void; + res.redirect = ((statusOrUrl: number | string, maybeUrl?: string | number): void => { + if (typeof statusOrUrl === 'number') original(statusOrUrl, appendIss(String(maybeUrl))); + // Express 4 still accepts the deprecated reversed form `res.redirect(url, status)`. + else if (typeof maybeUrl === 'number') original(appendIss(statusOrUrl), maybeUrl); + else original(appendIss(statusOrUrl)); + }) as Response['redirect']; + return res; +} + /** * Helper function to create redirect URL with error parameters */ -function createErrorRedirect(redirectUri: string, error: OAuthError, state?: string): string { +function createErrorRedirect(redirectUri: string, error: OAuthError, state?: string, issuer?: string): string { const errorUrl = new URL(redirectUri); errorUrl.searchParams.set('error', error.errorCode); errorUrl.searchParams.set('error_description', error.message); @@ -197,5 +245,9 @@ function createErrorRedirect(redirectUri: string, error: OAuthError, state?: str if (state) { errorUrl.searchParams.set('state', state); } + if (issuer) { + // RFC 9207 §2: the iss parameter is required on error responses too. + errorUrl.searchParams.set('iss', issuer); + } return errorUrl.href; } diff --git a/packages/server-legacy/src/auth/provider.ts b/packages/server-legacy/src/auth/provider.ts index 528e8d27bc..e197491025 100644 --- a/packages/server-legacy/src/auth/provider.ts +++ b/packages/server-legacy/src/auth/provider.ts @@ -10,6 +10,15 @@ export type AuthorizationParams = { codeChallenge: string; redirectUri: string; resource?: URL; + /** + * The authorization server's own issuer identifier (the `issuerUrl` configured on + * `mcpAuthRouter`). Informational: the bundled `authorizationHandler` already appends + * this as the `iss` query parameter (RFC 9207 §2) to any `res.redirect(...)` your + * `authorize()` issues to {@linkcode AuthorizationParams.redirectUri | redirectUri}. You + * only need to append it yourself when the final callback redirect is issued from a + * different response (e.g. after a separate consent-page POST). + */ + issuer?: string; }; /** @@ -27,6 +36,14 @@ export interface OAuthServerProvider { * This server must eventually issue a redirect with an authorization response or an error response to the given redirect URI. Per OAuth 2.1: * - In the successful case, the redirect MUST include the `code` and `state` (if present) query parameters. * - In the error case, the redirect MUST include the `error` query parameter, and MAY include an optional `error_description` query parameter. + * + * RFC 9207: the bundled `authorizationHandler` appends `iss` **only** to `res.redirect(...)` calls you issue + * on the supplied `res` to `params.redirectUri`, so an implementation that redirects that way requires no + * change. If you emit the `Location` header another way (e.g. `res.writeHead(302, { Location: ... })`), or + * issue the final callback redirect from a different response (e.g. after a separate consent step), append + * {@linkcode AuthorizationParams.issuer | params.issuer} as `iss` yourself, or set + * {@linkcode OAuthServerProvider.authorizationResponseIssParameterSupported} to `false` so the metadata does + * not over-claim. */ authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise; @@ -63,6 +80,16 @@ export interface OAuthServerProvider { */ revokeToken?(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise; + /** + * Whether this provider's authorization responses carry the RFC 9207 `iss` parameter. + * Drives the `authorization_response_iss_parameter_supported` metadata field. Defaults to + * `true` — the bundled `authorizationHandler` appends `iss` to redirects it issues to the + * client's `redirect_uri`. Set to `false` when the callback is issued by an upstream + * authorization server this provider delegates to (e.g. `ProxyOAuthServerProvider`), so the + * published metadata does not over-claim support. + */ + authorizationResponseIssParameterSupported?: boolean; + /** * Whether to skip local PKCE validation. * diff --git a/packages/server-legacy/src/auth/providers/proxyProvider.ts b/packages/server-legacy/src/auth/providers/proxyProvider.ts index b469ce6df0..495eab3c67 100644 --- a/packages/server-legacy/src/auth/providers/proxyProvider.ts +++ b/packages/server-legacy/src/auth/providers/proxyProvider.ts @@ -47,6 +47,17 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { skipLocalPkceValidation = true; + /** + * The proxy redirects the browser to the upstream AS's authorize endpoint with + * `redirect_uri = params.redirectUri`, so the upstream — not this proxy — issues the + * callback. The proxy cannot append its own `iss`, and any `iss` the upstream emits is the + * upstream's issuer, not `issuerUrl`. Advertise `false` so the metadata does not over-claim — + * a callback *without* `iss` then passes validation. Note: an upstream that *does* emit its + * own `iss` will still mismatch this proxy's issuer and be rejected by RFC 9207 clients + * regardless of this flag. + */ + authorizationResponseIssParameterSupported = false; + revokeToken?: (client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest) => Promise; constructor(options: ProxyOptions) { diff --git a/packages/server-legacy/src/auth/router.ts b/packages/server-legacy/src/auth/router.ts index ba8b030e08..02755cc7d7 100644 --- a/packages/server-legacy/src/auth/router.ts +++ b/packages/server-legacy/src/auth/router.ts @@ -61,7 +61,7 @@ export type AuthRouterOptions = { resourceServerUrl?: URL; // Individual options per route - authorizationOptions?: Omit; + authorizationOptions?: Omit; clientRegistrationOptions?: Omit; revocationOptions?: Omit; tokenOptions?: Omit; @@ -114,7 +114,14 @@ export const createOAuthMetadata = (options: { revocation_endpoint: revocation_endpoint ? new URL(revocation_endpoint, baseUrl || issuer).href : undefined, revocation_endpoint_auth_methods_supported: revocation_endpoint ? ['client_secret_post'] : undefined, - registration_endpoint: registration_endpoint ? new URL(registration_endpoint, baseUrl || issuer).href : undefined + registration_endpoint: registration_endpoint ? new URL(registration_endpoint, baseUrl || issuer).href : undefined, + + // RFC 9207: the bundled authorize handler appends `iss` to any redirect the provider + // issues via `res.redirect(...)` to the client's validated redirect_uri, so the default + // is `true`. Providers whose callback is issued by an upstream AS (e.g. the proxy + // provider) override this to `false` so we don't over-claim — SEP-2468 clients reject + // a callback that omits `iss` when support is advertised. + authorization_response_iss_parameter_supported: options.provider.authorizationResponseIssParameterSupported ?? true }; return metadata; @@ -139,7 +146,7 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { router.use( new URL(oauthMetadata.authorization_endpoint).pathname, - authorizationHandler({ provider: options.provider, ...options.authorizationOptions }) + authorizationHandler({ provider: options.provider, issuerUrl: options.issuerUrl, ...options.authorizationOptions }) ); router.use(new URL(oauthMetadata.token_endpoint).pathname, tokenHandler({ provider: options.provider, ...options.tokenOptions })); diff --git a/packages/server-legacy/test/auth/handlers/authorize.test.ts b/packages/server-legacy/test/auth/handlers/authorize.test.ts index cacecd8881..9a1edcff49 100644 --- a/packages/server-legacy/test/auth/handlers/authorize.test.ts +++ b/packages/server-legacy/test/auth/handlers/authorize.test.ts @@ -397,4 +397,78 @@ describe('Authorization Handler', () => { expect(location.searchParams.has('code')).toBe(true); }); }); + + describe('RFC 9207 iss parameter', () => { + const ISSUER = 'https://auth.example.com/'; + let issApp: express.Express; + + beforeEach(() => { + issApp = express(); + issApp.use('/authorize', authorizationHandler({ provider: mockProvider, issuerUrl: new URL(ISSUER) })); + }); + + it("appends iss to the provider's success redirect and supplies issuer to provider.authorize()", async () => { + // mockProvider.authorize() does NOT set `iss` itself — the handler must add it. + const spy = vi.spyOn(mockProvider, 'authorize'); + const response = await supertest(issApp).get('/authorize').query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + expect(response.status).toBe(302); + const location = new URL(response.header.location!); + expect(location.searchParams.get('code')).toBe('mock_auth_code'); + expect(location.searchParams.get('iss')).toBe(ISSUER); + expect(spy).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ issuer: ISSUER }), expect.anything()); + spy.mockRestore(); + }); + + it('leaves redirects to non-callback targets untouched', async () => { + // A provider that hops to an upstream authorize endpoint (proxy pattern) — the + // handler must not append `iss` to that hop. + const upstream = 'https://upstream.example.com/authorize?client_id=x'; + const proxyLike: OAuthServerProvider = { + ...mockProvider, + async authorize(_client, _params, res) { + res.redirect(upstream); + } + }; + const proxyApp = express(); + proxyApp.use('/authorize', authorizationHandler({ provider: proxyLike, issuerUrl: new URL(ISSUER) })); + const response = await supertest(proxyApp).get('/authorize').query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + expect(response.status).toBe(302); + expect(response.header.location).toBe(upstream); + }); + + it('appends iss to error redirects', async () => { + const response = await supertest(issApp).get('/authorize').query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'token' // invalid → error redirect + }); + expect(response.status).toBe(302); + const location = new URL(response.header.location!); + expect(location.searchParams.get('error')).toBe('invalid_request'); + expect(location.searchParams.get('iss')).toBe(ISSUER); + }); + + it('omits iss when issuerUrl is not configured', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'token' + }); + expect(response.status).toBe(302); + const location = new URL(response.header.location!); + expect(location.searchParams.has('iss')).toBe(false); + }); + }); }); diff --git a/packages/server-legacy/test/auth/providers/proxyProvider.test.ts b/packages/server-legacy/test/auth/providers/proxyProvider.test.ts index 1214ef5a05..e261925be4 100644 --- a/packages/server-legacy/test/auth/providers/proxyProvider.test.ts +++ b/packages/server-legacy/test/auth/providers/proxyProvider.test.ts @@ -106,6 +106,14 @@ describe('Proxy OAuth Server Provider', () => { expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString()); }); + + it('reports authorizationResponseIssParameterSupported = false (upstream issues the callback)', () => { + // The proxy cannot guarantee its own `iss` on the callback, so the metadata flag + // derived from the provider must be false — otherwise RFC 9207 clients reject any + // callback that arrives *without* `iss`. (A present upstream `iss` still mismatches + // the proxy issuer regardless of this flag.) + expect(provider.authorizationResponseIssParameterSupported).toBe(false); + }); }); describe('token exchange', () => { diff --git a/packages/server-legacy/test/auth/router.test.ts b/packages/server-legacy/test/auth/router.test.ts index f4c4472d08..b29f7509ad 100644 --- a/packages/server-legacy/test/auth/router.test.ts +++ b/packages/server-legacy/test/auth/router.test.ts @@ -218,6 +218,22 @@ describe('MCP Auth Router', () => { // Verify optional fields expect(response.body.service_documentation).toBe('https://docs.example.com/'); + + // RFC 9207: the bundled authorize handler emits `iss`, so the metadata advertises it. + expect(response.body.authorization_response_iss_parameter_supported).toBe(true); + }); + + it('derives authorization_response_iss_parameter_supported from the provider', async () => { + const optOutApp = express(); + optOutApp.use( + mcpAuthRouter({ + provider: { ...mockProvider, authorizationResponseIssParameterSupported: false }, + issuerUrl: new URL('https://auth.example.com') + }) + ); + const response = await supertest(optOutApp).get('/.well-known/oauth-authorization-server'); + expect(response.status).toBe(200); + expect(response.body.authorization_response_iss_parameter_supported).toBe(false); }); it('returns minimal metadata for minimal router', async () => { diff --git a/packages/server/README.md b/packages/server/README.md index 6f9ccf866c..0dfe938209 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -8,7 +8,7 @@ The MCP (Model Context Protocol) TypeScript server SDK. Build MCP servers that e > [!NOTE] -> This is **v2** of the MCP TypeScript SDK. It replaces the monolithic `@modelcontextprotocol/sdk` package from v1. See the **[migration guide](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration.md)** if you're coming from v1. +> This is **v2** of the MCP TypeScript SDK. It replaces the monolithic `@modelcontextprotocol/sdk` package from v1. See the **[migration guide](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration/upgrade-to-v2.md)** if you're coming from v1. ## Install diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index c33d394c8b..9b993eea1e 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -8,6 +8,15 @@ export type { CompletableSchema, CompleteCallback } from './server/completable.js'; export { completable, isCompletable } from './server/completable.js'; +export type { + CreateMcpHandlerOptions, + LegacyHttpHandler, + McpHandlerRequestOptions, + McpHttpHandler, + McpRequestContext, + McpServerFactory +} from './server/createMcpHandler.js'; +export { createMcpHandler, isLegacyRequest, legacyStatelessFallback } from './server/createMcpHandler.js'; export type { AnyToolHandler, BaseToolCallback, @@ -26,11 +35,22 @@ 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'; +// Opt-in HMAC sealing for the multi-round-trip requestState (SEP-2322): the +// convenience codec consumers drop into ServerOptions.requestState.verify. +export type { RequestStateCodec, RequestStateCodecOptions } from './server/requestStateCodec.js'; +export { createRequestStateCodec } from './server/requestStateCodec.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 -// imports (erased at compile time), but matching the client's `./stdio` subpath gives consumers a -// consistent shape across packages. +// subscriptions/listen change-event sourcing seam (protocol revision 2026-07-28). +export type { ServerEvent, ServerEventBus, ServerNotifier } from './server/serverEventBus.js'; +export { InMemoryServerEventBus } from './server/serverEventBus.js'; +// StdioServerTransport and the serveStdio entry are exported from the './stdio' subpath — server stdio +// has only type-level Node imports (erased at compile time), but matching the client's `./stdio` subpath +// gives consumers a consistent shape across packages. export type { EventId, EventStore, @@ -43,5 +63,28 @@ 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'; + +// Multi round-trip requests (protocol revision 2026-07-28): the authoring +// helpers a handler uses to request additional client input by returning an +// input-required result instead of sending a server→client request. +export type { InputRequiredSpec } from '@modelcontextprotocol/core'; +export { acceptedContent, inputRequired } 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..c1b0656873 --- /dev/null +++ b/packages/server/src/server/createMcpHandler.ts @@ -0,0 +1,874 @@ +/** + * `createMcpHandler` — the HTTP entry point for serving the 2026-07-28 protocol + * revision, with old-school stateless 2025-era serving as the default fallback. + * + * 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. + * By default they are served per request through the stateless idiom from + * the same factory (`legacy: 'stateless'`); with `legacy: 'reject'` the + * endpoint is modern-only strict and answers the documented rejection cells + * instead — there is no 2025 serving in that mode. + * + * There is no handler-valued `legacy` option: an existing legacy deployment + * (for example a sessionful streamable HTTP wiring) keeps serving 2025 traffic + * by routing in user land with {@linkcode isLegacyRequest} — the entry's own + * classification step, exported as a predicate — in front of a strict + * (`legacy: 'reject'`) handler. + * + * 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, + InboundClassificationOutcome, + InboundLadderRejection, + InboundLegacyRoute, + InboundModernRoute, + RequestId +} from '@modelcontextprotocol/core'; +import { + classifyInboundRequest, + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + httpStatusForErrorCode, + missingClientCapabilities, + MissingRequiredClientCapabilityError, + modernOnlyStrictRejection, + requestMetaOf, + requiredClientCapabilitiesForRequest, + scanXMcpHeaderDeclarations, + SdkError, + SdkErrorCode, + setNegotiatedProtocolVersion, + SUPPORTED_MODERN_PROTOCOL_VERSIONS, + UnsupportedProtocolVersionError, + validateMcpParamHeaders, + validateStandardRequestHeaders +} from '@modelcontextprotocol/core'; + +import { invoke } from './invoke.js'; +import { createListenRouter, DEFAULT_LISTEN_KEEPALIVE_MS, DEFAULT_MAX_SUBSCRIPTIONS } from './listenRouter.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 type { ServerEventBus, ServerNotifier } from './serverEventBus.js'; +import { createServerNotifier, InMemoryServerEventBus } from './serverEventBus.js'; +import { WebStandardStreamableHTTPServerTransport } from './streamableHttp.js'; + +/* ------------------------------------------------------------------------ * + * Factory and handler types + * ------------------------------------------------------------------------ */ + +/** + * Construction context handed to an {@linkcode McpServerFactory}. + * + * Both serving entries call the factory with this context whenever they need + * a fresh instance: {@linkcode createMcpHandler} once per HTTP request, and + * `serveStdio` (from `@modelcontextprotocol/server/stdio`) once per + * connection — plus once for a `server/discover` probe instance that is + * discarded again if the client falls back to `initialize`. + * + * 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 the constructed instance will serve: `modern` for + * 2026-07-28 (per-request envelope) traffic, `legacy` for 2025-era + * traffic. Under {@linkcode createMcpHandler} a `legacy` instance serves + * one request through the stateless legacy fallback (the default — + * `legacy: 'reject'` endpoints are strict and never construct one); under + * `serveStdio` it serves a connection that opened with the 2025 handshake + * and stays pinned to that era for its lifetime. + */ + era: 'legacy' | 'modern'; + /** + * Validated authentication information passed by the caller of the + * handler face (pass-through; HTTP only — `serveStdio` never sets it). + */ + authInfo?: AuthInfo; + /** The original HTTP request being served, when available (HTTP only — `serveStdio` never sets it). */ + requestInfo?: Request; +} + +/** + * A factory producing a fresh {@linkcode McpServer} (or low-level + * {@linkcode Server}) instance for one serving unit: one HTTP request under + * {@linkcode createMcpHandler}, or one connection (or one discarded + * `server/discover` probe) under `serveStdio`. The same factory backs every + * era either entry serves — 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 fetch-shaped legacy handlers ({@linkcode LegacyHttpHandler}). */ +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: the shape produced by + * {@linkcode legacyStatelessFallback}, and the shape a hand-wired composition + * routes legacy requests to (see {@linkcode isLegacyRequest}). It is not a + * `legacy` option value — the entry's own legacy serving is selected by the + * `'stateless' | 'reject'` posture only. + */ +export type LegacyHttpHandler = (request: Request, options?: McpHandlerRequestOptions) => Promise; + +/** Options for {@linkcode createMcpHandler}. */ +export interface CreateMcpHandlerOptions { + /** + * How 2025-era (non-envelope) traffic is served: + * + * - `'stateless'` (the default, also when the option is omitted) — + * old-school stateless serving: each legacy request is answered by a + * fresh instance from the same factory over a streamable HTTP transport + * constructed with only `sessionIdGenerator: undefined` (the established + * stateless idiom). Because serving is per-request and stateless, GET and + * DELETE (2025 session operations) are answered with `405` / + * `Method not allowed.`. + * - `'reject'` — 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 2025 serving in + * this mode.** + * + * There is no handler-valued option: to keep an existing legacy deployment + * (for example a sessionful streamable HTTP wiring) serving 2025 traffic + * next to this entry, route in user land with {@linkcode isLegacyRequest} + * in front of a `legacy: 'reject'` handler — see that predicate's + * documentation for the pattern. + */ + legacy?: 'stateless' | 'reject'; + /** 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; + /** + * The change-event bus `subscriptions/listen` streams subscribe to. + * + * When omitted, an in-process {@link InMemoryServerEventBus} is created + * and the returned handler's `notify` sugar publishes onto it. + * Multi-process deployments supply their own implementation over their + * pub/sub backend; the same instance can be shared across handlers. + */ + bus?: ServerEventBus; + /** + * Reject a new `subscriptions/listen` with `-32603` 'Subscription limit + * reached' (in-band, HTTP 200, before the ack) when this many subscription + * streams are already open on this handler. + * @default 1024 + */ + maxSubscriptions?: number; + /** + * SSE comment-frame keepalive interval for `subscriptions/listen` streams, + * in milliseconds. Set to `0` to disable. + * @default 15000 + */ + keepAliveMs?: number; +} + +/** + * The handler returned by {@linkcode createMcpHandler}: a web-standard + * `{ fetch, close, notify, bus }` object — the shape Workers/Bun/Deno expect + * from `export default`. `fetch` is an arrow-assigned bound property: it can be + * detached and passed around (`const { fetch } = handler`) without losing its + * binding. + * + * Node frameworks (Express, Fastify, plain `node:http`) wrap the handler once + * with `toNodeHandler(handler)` from `@modelcontextprotocol/node`. + */ +export interface McpHttpHandler { + /** Web-standard face: serve one HTTP request and resolve with the response. */ + fetch: (request: Request, options?: McpHandlerRequestOptions) => Promise; + /** + * Tears down the modern leg: aborts in-flight modern exchanges and closes + * their per-request instances. Legacy serving is unaffected — the + * stateless fallback is per-request by construction and holds nothing + * between exchanges. + */ + close: () => Promise; + /** + * Typed publish-side facade over the handler's `subscriptions/listen` bus: + * each method publishes the corresponding change event to every open + * subscription stream that opted in to that notification type. + * + * Safe to call when no subscription is open (no-op). + */ + notify: ServerNotifier; + /** + * The change-event bus this handler's `subscriptions/listen` streams + * subscribe to (the supplied `bus` option, or the auto-created in-process + * default). + */ + bus: ServerEventBus; +} + +/* ------------------------------------------------------------------------ * + * 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)); +} + +function internalServerErrorResponse(id: RequestId | null = null): Response { + return jsonRpcErrorResponse(500, -32_603, 'Internal server error', undefined, id); +} + +/* ------------------------------------------------------------------------ * + * The default legacy fallback + * ------------------------------------------------------------------------ */ + +/** + * The entry's default legacy serving (`legacy: 'stateless'`): per-request + * stateless serving of 2025-era traffic using the same factory as the modern + * path. Exported as a standalone building block for hand-wired compositions + * (for example mounting legacy stateless serving on its own route next to a + * strict modern endpoint). + * + * 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. + * + * 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 the default, 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's classification step (shared with isLegacyRequest) + * ------------------------------------------------------------------------ */ + +/** The outcome of the entry's classification step for one inbound HTTP request. */ +type EntryClassification = + /** The body bytes could not be read at all (a failing stream, not malformed JSON). */ + | { step: 'unreadable-body' } + /** A POST with an empty or non-JSON body: nothing to classify, so there is no envelope claim. */ + | { step: 'no-json-body'; forwardRequest: Request } + /** A classifiable request, with the classifier's routing outcome. */ + | { step: 'classified'; outcome: InboundClassificationOutcome; body: unknown; parsedBody: unknown; forwardRequest: Request }; + +/** + * The entry's classification step: read the request body exactly once (unless + * a pre-parsed body is supplied) and classify the request with + * {@linkcode classifyInboundRequest}. This is the single code path behind both + * {@linkcode createMcpHandler}'s routing and the exported + * {@linkcode isLegacyRequest} predicate, so the two can never disagree. + * + * Pass `needsForward: false` when the caller never reads `forwardRequest` — + * the body-preserving clone is then skipped and `forwardRequest` is the + * (consumed) input request. + */ +async function classifyEntryRequest(request: Request, providedParsedBody?: unknown, needsForward = true): Promise { + const httpMethod = request.method.toUpperCase(); + + let body: unknown; + let parsedBody = providedParsedBody; + 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 leg (web-standard + // request bodies are single-use) when the caller needs it. + if (needsForward) { + forwardRequest = request.clone(); + } + let bodyText: string; + try { + bodyText = await request.text(); + } catch { + return { step: 'unreadable-body' }; + } + 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) { + return { step: 'no-json-body', forwardRequest }; + } + } + + const outcome = classifyInboundRequest({ + httpMethod, + protocolVersionHeader: request.headers.get('mcp-protocol-version') ?? undefined, + mcpMethodHeader: request.headers.get('mcp-method') ?? undefined, + mcpNameHeader: request.headers.get('mcp-name') ?? undefined, + ...(body !== undefined && { body }) + }); + return { step: 'classified', outcome, body, parsedBody, forwardRequest }; +} + +/** + * Whether {@linkcode createMcpHandler} would route this request to its legacy + * (2025-era) serving rather than the modern (2026-07-28) path. + * + * This is the entry's own classification step exported as a predicate — it + * runs exactly the code `createMcpHandler` runs to make the routing decision, + * not a re-implementation — so a hand-wired composition that branches on it + * can never disagree with the entry. Use it to keep an existing legacy + * deployment (for example a sessionful streamable HTTP wiring) serving 2025 + * traffic next to a strict modern endpoint, now that the entry has no + * handler-valued `legacy` option: + * + * ```ts + * import { createMcpHandler, isLegacyRequest } from '@modelcontextprotocol/server'; + * + * const modern = createMcpHandler(factory, { legacy: 'reject' }); + * + * export default { + * async fetch(request: Request): Promise { + * if (await isLegacyRequest(request)) { + * // e.g. an existing sessionful WebStandardStreamableHTTPServerTransport wiring + * return myExistingLegacyHandler(request); + * } + * return modern.fetch(request); + * } + * }; + * ``` + * + * Semantics (identical to the entry's routing): + * + * - Returns `true` only for requests with no per-request `_meta` envelope + * claim: claim-less POSTs (including the `initialize` handshake and 2025-era + * notification POSTs without a modern protocol-version header), body-less + * GET/DELETE session operations, all-legacy JSON-RPC batch arrays, posted + * JSON-RPC responses, and POSTs whose body is empty or not valid JSON. + * - Returns `false` for everything the modern path answers, including its + * validation-ladder rejections: a request carrying the envelope claim (even + * one naming a revision the endpoint does not serve — the modern path + * answers it with the unsupported-protocol-version error), a malformed + * envelope behind a present claim (answered `-32602`), a request whose + * `MCP-Protocol-Version` header names a modern revision but that lacks the + * envelope (`-32602`), and header/body mismatches (`-32020`). Consumers + * routing on the predicate must send `false` traffic to the modern handler, + * never to a legacy handler — the modern path owns those error answers. + * - `server/discover` probes sent by negotiating clients always carry the + * envelope claim, so they are never legacy; a hand-built claim-less POST to + * a method named `server/discover` has no claim and classifies legacy, + * exactly as the entry itself routes it. + * + * The body is read from a clone, so the passed request stays readable for + * whichever handler the caller routes it to. If the body has already been + * consumed (for example behind `express.json()`), pass the parsed body as the + * second argument and no body read happens at all — without it the predicate + * cannot classify a consumed POST body (cloning a used body throws a + * `TypeError`), so the call rejects instead of guessing. + */ +export async function isLegacyRequest(request: Request, parsedBody?: unknown): Promise { + // Classify a clone so the caller's request body stays readable; with a + // pre-parsed body (or a body-less method) nothing is read and no clone is + // needed. The predicate never reads forwardRequest, so the classification + // step's own forwarding clone is skipped. + const probe = parsedBody === undefined && request.method.toUpperCase() === 'POST' ? request.clone() : request; + const classified = await classifyEntryRequest(probe, parsedBody, false); + return classified.step === 'no-json-body' || (classified.step === 'classified' && classified.outcome.kind === 'legacy'); +} + +/* ------------------------------------------------------------------------ * + * The entry + * ------------------------------------------------------------------------ */ + +/** + * Creates an HTTP handler that serves the 2026-07-28 protocol revision from a + * per-request server factory and, by default, falls back to old-school + * stateless serving for 2025-era traffic. Pass `legacy: 'reject'` for a + * modern-only strict endpoint. + * + * Mounting: `handler.fetch` is the web-standard face (Cloudflare Workers, + * Deno, Bun, Hono's `c.req.raw`); for Express/Fastify/plain `node:http`, wrap + * the handler once with `toNodeHandler(handler)` from + * `@modelcontextprotocol/node`. 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 stateless legacy fallback, so the two eras can + * never drift apart. To keep an existing legacy deployment (for example a + * sessionful streamable HTTP wiring) serving 2025 traffic instead of the + * stateless fallback, route in user land with {@linkcode isLegacyRequest} in + * front of a strict handler — see that predicate's documentation for the + * pattern. Power users composing transport-neutral routing can also 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 `fetch` 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; + + // Construction-time guard for JavaScript callers passing a handler as the + // legacy value: the option only selects a posture ('stateless' | 'reject'). + // Failing loudly here beats silently treating the handler as the default. + if (typeof legacy === 'function') { + throw new TypeError( + "The 'legacy' option only accepts 'stateless' or 'reject', not a handler function. To serve 2025-era traffic with your own " + + "handler, route in user land with the exported isLegacyRequest(request) predicate in front of a strict (legacy: 'reject') handler." + ); + } + + /** Modern per-request instances with an exchange still in flight (close() tears these down). */ + const inflight = new Set(); + let closed = false; + + const reportError = (error: Error) => { + try { + onerror?.(error); + } catch { + // Reporting must never alter the response. + } + }; + + const bus: ServerEventBus = options.bus ?? new InMemoryServerEventBus(reportError); + const notify = createServerNotifier(bus); + const listenRouter = createListenRouter({ + bus, + maxSubscriptions: options.maxSubscriptions ?? DEFAULT_MAX_SUBSCRIPTIONS, + keepAliveMs: options.keepAliveMs ?? DEFAULT_LISTEN_KEEPALIVE_MS, + onerror: reportError + }); + if (responseMode === 'json') { + // eslint-disable-next-line no-console + console.warn( + "responseMode: 'json' drops mid-call notifications. subscriptions/listen streams are always served over SSE regardless; " + + 'other notifications emitted before a result are dropped.' + ); + } + + // The default posture is the stateless fallback; 'reject' is the only way + // to turn legacy serving off (modern-only strict). + const legacyHandler: LegacyHttpHandler | undefined = legacy === 'reject' ? undefined : legacyStatelessFallback(factory, reportError); + + async function serveModern(route: InboundModernRoute, 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(route.message)); + } + + // SEP-2243 standard-header presence and `Mcp-Name` cross-check + // (`standard-header-validation` rung; the `MCP-Protocol-Version` and + // `Mcp-Method` *mismatch* cells are already answered inside + // `classifyInboundRequest` on the edge `era-classification` rung). + // Evaluated after the supported-revision + // gate so an envelope naming a revision this endpoint does not serve + // is still answered with `-32022` (the supported list is the more + // useful answer to a client speaking the wrong revision); evaluated + // before the capability gate, the factory call, and the + // `Mcp-Param-*` rung so a request that fails several rungs is + // answered by the standard-header rung first. + const stdHeaderRejection = validateStandardRequestHeaders( + { + httpMethod: request.method, + mcpMethodHeader: request.headers.get('mcp-method') ?? undefined, + mcpNameHeader: request.headers.get('mcp-name') ?? undefined + }, + route + ); + if (stdHeaderRejection !== undefined) { + reportError(new Error(`Rejected inbound request (${stdHeaderRejection.cell}): ${stdHeaderRejection.message}`)); + return rejectionResponse(stdHeaderRejection, echoableRequestId(route.message)); + } + + const meta = route.messageKind === 'request' ? requestMetaOf(route.message.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(route.message.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, + route.message.id + ); + } + } + } + + const product = await factory({ + era: 'modern', + ...(authInfo !== undefined && { authInfo }), + requestInfo: request + }); + const server = product instanceof McpServer ? product.server : product; + + // Entry-handled `subscriptions/listen`: the router owns ack-first / + // per-stream filtering / subscription-id stamping / keepalive / + // capacity / teardown. The factory IS constructed for listen — to read + // the instance's declared capabilities only, so the acknowledged + // filter reflects what the server can actually deliver. Unlike the + // discover path (which connects via the per-request transport and tears + // down with it), the probe instance is never connected: capabilities + // are read off the unconnected instance and it is closed immediately. + // Authorization the consumer performs inside the factory therefore DOES + // see listen requests, although token verification still belongs at the + // middleware layer mounted in front of this entry. + if (route.messageKind === 'request' && route.message.method === 'subscriptions/listen') { + const capabilities = server.getCapabilities(); + void product.close().catch(reportError); + return listenRouter.serve(route.message, request.signal, capabilities); + } + + // SEP-2243 `Mcp-Param-*` server-side validation (pre-dispatch ladder + // rung): for a `tools/call`, look up the named tool's JSON inputSchema + // on the just-produced instance and compare every `x-mcp-header` + // declaration against the request's `Mcp-Param-{Name}` headers and the + // body `arguments`. A mismatch (or a missing header for a present body + // value, or an invalid Base64 sentinel) emits the same `400` / + // `-32020` (`HeaderMismatch`) shape the edge cross-checks use. Only + // applied when the factory returns an `McpServer` (the registry is the + // schema source); a low-level `Server` factory has no registry, so + // there is nothing to validate against. + if (route.messageKind === 'request' && route.message.method === 'tools/call' && product instanceof McpServer) { + const callParams = route.message.params as { name?: string; arguments?: Record } | undefined; + const toolName = typeof callParams?.name === 'string' ? callParams.name : undefined; + const inputSchema = toolName === undefined ? undefined : product.toolInputSchemaJson(toolName); + if (inputSchema !== undefined) { + const scan = scanXMcpHeaderDeclarations(inputSchema); + if (scan.valid && scan.declarations.length > 0) { + const rejection = validateMcpParamHeaders(scan.declarations, callParams?.arguments, request.headers); + if (rejection !== undefined) { + void product.close().catch(reportError); + reportError(new Error(`Rejected inbound request (${rejection.cell}): ${rejection.message}`)); + return rejectionResponse(rejection, route.message.id); + } + } + } + } + + // 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 + }); + } + + // 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?.(); + }; + + try { + const response = await invoke(product, route.message, { + classification: route.classification, + request, + ...(authInfo !== undefined && { authInfo }), + ...(responseMode !== undefined && { responseMode }) + }); + 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(route.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 authInfo = requestOptions?.authInfo; + const classified = await classifyEntryRequest(request, requestOptions?.parsedBody); + + if (classified.step === 'unreadable-body') { + return jsonRpcErrorResponse(400, -32_700, 'Parse error: the request body could not be read'); + } + if (classified.step === 'no-json-body') { + // No JSON body to classify: there is no envelope claim, so this is + // legacy traffic when legacy serving is configured (the legacy leg + // answers its own parse error, unchanged), and a parse error + // otherwise. + if (legacyHandler !== undefined) { + return legacyHandler(classified.forwardRequest, { ...(authInfo !== undefined && { authInfo }) }); + } + return jsonRpcErrorResponse(400, -32_700, 'Parse error: the request body is not valid JSON'); + } + + const { outcome, body, parsedBody, forwardRequest } = classified; + 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, request, authInfo); + } + case 'legacy': { + return await serveLegacyRoute(outcome, forwardRequest, authInfo, parsedBody); + } + } + } catch (error) { + // Entry-internal failure while serving a classified request (a + // throwing factory or a failed connect, on either leg): 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)); + } + }; + + return { + fetch: fetchFace, + notify, + bus, + close: async () => { + closed = true; + listenRouter.closeAll(); + const closing = [...inflight].map(server => server.close().catch(() => {})); + inflight.clear(); + await Promise.all(closing); + } + }; +} 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/listenRouter.ts b/packages/server/src/server/listenRouter.ts new file mode 100644 index 0000000000..4bff1c2a70 --- /dev/null +++ b/packages/server/src/server/listenRouter.ts @@ -0,0 +1,412 @@ +/** + * The entry-handled `subscriptions/listen` router for the HTTP serving entry. + * + * `createMcpHandler` recognizes a modern-classified `subscriptions/listen` + * request and routes it here: the entry owns ack-first, per-stream filtering, + * subscription-id stamping, keepalive, capacity guarding, and teardown. The + * consumer's factory IS constructed for listen, to read the instance's + * declared `ServerCapabilities` only — the probe instance is never connected + * and is closed immediately after the capabilities read. Token verification + * and any per-request authorization still belong at the middleware layer + * mounted in front of `createMcpHandler` (the entry's documented authz + * posture). + * + * Per the spec at protocol revision 2026-07-28: + * - The acknowledged notification is the FIRST message on the stream and + * carries the honored subset of the requested filter. + * - Every notification on the stream (including the ack) carries the listen + * request's JSON-RPC id under `_meta['io.modelcontextprotocol/subscriptionId']`. + * - The server MUST NOT deliver a notification type the client did not request. + * - Server-side graceful close (`closeAll()`) emits the empty + * `subscriptions/listen` JSON-RPC result (the `SubscriptionsListenResult` — + * `_meta` carries the subscription id) before closing the stream; an abrupt + * transport close carries no response and the client treats it as a + * disconnect. + */ +import type { JSONRPCRequest, RequestId, ServerCapabilities, SubscriptionFilter } from '@modelcontextprotocol/core'; +import { codecForVersion, MODERN_WIRE_REVISION, SUBSCRIPTION_ID_META_KEY } from '@modelcontextprotocol/core'; + +import type { ServerEventBus } from './serverEventBus.js'; +import { honoredSubset, listenFilterAccepts, serverEventToNotification } from './serverEventBus.js'; + +/** Default SSE comment-frame keepalive interval for listen streams. */ +export const DEFAULT_LISTEN_KEEPALIVE_MS = 15_000; + +/** Default capacity guard: refuse a new subscription when this many are already open. */ +export const DEFAULT_MAX_SUBSCRIPTIONS = 1024; + +/** Options for {@linkcode createListenRouter}. */ +export interface ListenRouterOptions { + /** The event bus listen streams subscribe to. */ + bus: ServerEventBus; + /** Reject a new listen with `-32603` when this many subscriptions are already open (default 1024). */ + maxSubscriptions?: number; + /** SSE comment-frame keepalive interval; `0` disables keepalive (default 15000). */ + keepAliveMs?: number; + /** Out-of-band error reporting (never alters the response). */ + onerror?: (error: Error) => void; +} + +/** + * A wire-shape notification body (method + loose params). + * @internal + */ +export interface NotificationBody { + method: string; + params: { _meta?: Record; [key: string]: unknown }; +} + +function jsonRpcError(id: RequestId | null, code: number, message: string): Response { + return Response.json({ jsonrpc: '2.0', error: { code, message }, id }, { status: 200 }); +} + +/** Stamp the subscription id onto a notification's `_meta`. Non-mutating. */ +function stampSubscriptionId( + notification: { method: string; params?: { _meta?: Record; [key: string]: unknown } }, + subscriptionId: RequestId +): NotificationBody { + return { + method: notification.method, + params: { + ...notification.params, + _meta: { ...notification.params?._meta, [SUBSCRIPTION_ID_META_KEY]: subscriptionId } + } + }; +} + +/** + * Read the requested filter off a `subscriptions/listen` request body. + * Returns the validated filter, or `undefined` when `params.notifications` + * is absent or fails the schema (the caller answers `-32602` — the spec + * marks `notifications` REQUIRED on the listen request). + */ +export function parseListenFilter(message: JSONRPCRequest): SubscriptionFilter | undefined { + // `subscriptions/listen` is 2026-only vocabulary; route through the era + // codec's request validator (the wire layer owns the filter schema). + const outcome = codecForVersion(MODERN_WIRE_REVISION).validateRequest('subscriptions/listen', message); + return outcome.ok ? outcome.value.params?.notifications : undefined; +} + +/** + * The HTTP listen router: holds the set of open subscriptions and serves + * each listen request as an SSE response. + */ +export interface ListenRouter { + /** + * Serve one `subscriptions/listen` request and return the SSE `Response` + * (or, on capacity / params rejection, the in-band JSON-RPC error + * `Response`). The ack notification is the first SSE frame. + * + * `capabilities` is required: the acknowledged filter is always narrowed + * against what the serving instance advertises (honoring a filter without + * capabilities would fail open and deliver unadvertised types). + */ + serve(message: JSONRPCRequest, signal: AbortSignal | undefined, capabilities: ServerCapabilities): Response; + /** + * Gracefully close every open subscription stream: emits the empty + * `subscriptions/listen` JSON-RPC result (the spec's graceful-close + * signal) as the final SSE frame, then closes the stream. + */ + closeAll(): void; + /** The number of currently open subscription streams (for tests / introspection). */ + readonly openCount: number; +} + +export function createListenRouter(options: ListenRouterOptions): ListenRouter { + const { bus, onerror } = options; + const maxSubscriptions = options.maxSubscriptions ?? DEFAULT_MAX_SUBSCRIPTIONS; + const keepAliveMs = options.keepAliveMs ?? DEFAULT_LISTEN_KEEPALIVE_MS; + + const open = new Set<(graceful: boolean) => void>(); + + function serve(message: JSONRPCRequest, signal: AbortSignal | undefined, capabilities: ServerCapabilities): Response { + // Capacity guard, pre-ack: in-band -32603 on HTTP 200. + if (open.size >= maxSubscriptions) { + onerror?.(new Error(`subscriptions/listen refused: subscription limit reached (${maxSubscriptions})`)); + return jsonRpcError(message.id, -32_603, 'Subscription limit reached'); + } + const filter = parseListenFilter(message); + if (filter === undefined) { + return jsonRpcError(message.id, -32_602, "Invalid params: 'notifications' is required and must be a valid SubscriptionFilter"); + } + const honored = honoredSubset(filter, capabilities); + // The spec carries the listen request's JSON-RPC id verbatim as the + // subscription id; demux is per-connection (each HTTP listen has its + // own SSE stream) so client-chosen ids cannot route across requests. + const subscriptionId = message.id; + + const encoder = new TextEncoder(); + let controller!: ReadableStreamDefaultController; + let closed = false; + let unsubscribe: (() => void) | undefined; + let keepAliveTimer: ReturnType | undefined; + let abortCleanup: (() => void) | undefined; + + const writeFrame = (frame: string) => { + if (closed) return; + try { + controller.enqueue(encoder.encode(frame)); + } catch (error) { + onerror?.(error instanceof Error ? error : new Error(String(error))); + } + }; + const writeNotification = (method: string, params: { _meta?: Record; [key: string]: unknown }) => { + writeFrame(`event: message\ndata: ${JSON.stringify({ jsonrpc: '2.0', method, params })}\n\n`); + }; + + const teardown = (graceful: boolean) => { + if (closed) return; + if (graceful) { + // Server-side graceful close: emit the empty + // `subscriptions/listen` JSON-RPC result before closing the + // stream so the client distinguishes graceful end from a + // transport drop. Written before `closed = true` so writeFrame + // still enqueues. + writeFrame( + `event: message\ndata: ${JSON.stringify({ + jsonrpc: '2.0', + id: subscriptionId, + result: { resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: subscriptionId } } + })}\n\n` + ); + } + closed = true; + unsubscribe?.(); + if (keepAliveTimer !== undefined) clearInterval(keepAliveTimer); + abortCleanup?.(); + open.delete(teardown); + try { + controller.close(); + } catch { + // Already closed/cancelled by the consumer. + } + }; + + const readable = new ReadableStream({ + start(streamController) { + controller = streamController; + + // Ack-first MUST: the acknowledged notification is the first + // frame on the stream, stamped with the subscription id. + const ack = stampSubscriptionId( + { method: 'notifications/subscriptions/acknowledged', params: { notifications: honored } }, + subscriptionId + ); + writeNotification(ack.method, ack.params); + + // Only after the ack frame is enqueued does delivery activate. + unsubscribe = bus.subscribe(event => { + if (closed || !listenFilterAccepts(honored, event)) return; + const note = stampSubscriptionId(serverEventToNotification(event), subscriptionId); + writeNotification(note.method, note.params); + }); + + if (keepAliveMs > 0) { + keepAliveTimer = setInterval(() => writeFrame(': keepalive\n\n'), keepAliveMs); + // Do not hold the event loop open on idle subscriptions. Node's + // setInterval returns a Timeout with .unref(); browsers/Workers + // return a number — the cast is an environment shim, not a + // workaround for SDK typing. + (keepAliveTimer as { unref?: () => void }).unref?.(); + } + + open.add(teardown); + }, + cancel() { + // The client closed the SSE stream — the spec's HTTP cancel + // signal. Not a server-side graceful close, so no listen + // result is written (and the consumer is gone anyway). + teardown(false); + } + }); + + if (signal !== undefined) { + if (signal.aborted) { + teardown(false); + } else { + const onAbort = () => teardown(false); + signal.addEventListener('abort', onAbort, { once: true }); + abortCleanup = () => signal.removeEventListener('abort', onAbort); + } + } + + return new Response(readable, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' + } + }); + } + + return { + serve, + closeAll() { + for (const teardown of open) teardown(true); + }, + get openCount() { + return open.size; + } + }; +} + +/* ------------------------------------------------------------------------ * + * Stdio listen router + * ------------------------------------------------------------------------ */ + +const CHANGE_NOTIFICATION_METHODS: ReadonlySet = new Set([ + 'notifications/tools/list_changed', + 'notifications/prompts/list_changed', + 'notifications/resources/list_changed', + 'notifications/resources/updated' +]); + +/** + * Per-connection listen state for the stdio entry. One instance is held by + * `serveStdio` for the connection lifetime; it routes inbound + * `subscriptions/listen` / `notifications/cancelled` and rewrites outbound + * change notifications onto the active subscriptions. No bus — the long-lived + * pinned instance's existing `send*ListChanged()` calls feed straight into + * `routeOutbound()`. + */ +export class StdioListenRouter { + /** Active subscriptions, keyed by the listen request's JSON-RPC id verbatim. */ + private readonly _subs = new Map(); + /** + * The serving instance's declared capabilities. Filled in by the entry + * once the modern instance is constructed (the router is created before + * the instance exists), so the acknowledged filter is narrowed against + * what the server can actually deliver. + */ + private _serverCapabilities: ServerCapabilities | undefined; + + constructor( + private readonly _maxSubscriptions: number = DEFAULT_MAX_SUBSCRIPTIONS, + serverCapabilities?: ServerCapabilities + ) { + this._serverCapabilities = serverCapabilities; + } + + /** + * Record the serving instance's declared capabilities once it has been + * constructed. Called by `serveStdio`'s connect path; subsequent + * `serve()` calls narrow the honored filter against these. + */ + setServerCapabilities(capabilities: ServerCapabilities): void { + this._serverCapabilities = capabilities; + } + + /** Whether `id` is an active listen subscription on this connection. */ + has(id: RequestId): boolean { + return this._subs.has(id); + } + + /** + * Serve one inbound `subscriptions/listen` request: registers the + * subscription and returns the stamped acknowledged notification (or, on + * capacity / params rejection, the in-band JSON-RPC error response). + * + * @throws when called before {@linkcode setServerCapabilities} (or the + * constructor) has supplied the serving instance's capabilities. Honoring a + * filter without knowing the server's advertised capabilities would fail + * open (deliver unadvertised types); the entry guarantees capabilities are + * set before any listen request is routed here. + */ + serve(message: JSONRPCRequest): NotificationBody | { jsonrpc: '2.0'; id: RequestId; error: { code: number; message: string } } { + if (this._serverCapabilities === undefined) { + throw new Error( + 'StdioListenRouter.serve() called before setServerCapabilities(); refusing to honor a filter without capabilities' + ); + } + if (this._subs.size >= this._maxSubscriptions) { + return { jsonrpc: '2.0', id: message.id, error: { code: -32_603, message: 'Subscription limit reached' } }; + } + const filter = parseListenFilter(message); + if (filter === undefined) { + return { + jsonrpc: '2.0', + id: message.id, + error: { code: -32_602, message: "Invalid params: 'notifications' is required and must be a valid SubscriptionFilter" } + }; + } + const honored = honoredSubset(filter, this._serverCapabilities); + this._subs.set(message.id, honored); + return stampSubscriptionId({ method: 'notifications/subscriptions/acknowledged', params: { notifications: honored } }, message.id); + } + + /** + * Tear down one subscription (inbound `notifications/cancelled`). Returns + * `true` when a subscription was removed. After this call NOTHING further + * is delivered for that subscription id (the post-cancel hardening). + */ + cancel(id: RequestId): boolean { + return this._subs.delete(id); + } + + /** + * Route an outbound notification through the active subscriptions. + * + * - For a subscription-gated change notification, returns one stamped copy + * per subscription that opted in to it (an empty array means it is + * dropped — the modern era never delivers an un-requested change type). + * - For any other outbound message, returns `'passthrough'` (the entry + * forwards it as-is). + */ + routeOutbound(message: { method: string; params?: { [key: string]: unknown } }): NotificationBody[] | 'passthrough' { + if (!CHANGE_NOTIFICATION_METHODS.has(message.method)) { + return 'passthrough'; + } + const uriParam: unknown = message.params?.['uri']; + const uri = typeof uriParam === 'string' ? uriParam : undefined; + const event = notificationToServerEvent(message.method, uri); + const out: NotificationBody[] = []; + for (const [subscriptionId, filter] of this._subs) { + if (listenFilterAccepts(filter, event)) { + out.push(stampSubscriptionId({ method: message.method, params: message.params ?? {} }, subscriptionId)); + } + } + return out; + } + + /** + * Server-side graceful teardown of every active subscription: returns the + * empty `subscriptions/listen` JSON-RPC result for each subscription id — + * the spec's graceful-close signal — for the entry to emit before closing + * the wire. Clears the set so nothing further is delivered. + */ + teardownAll(): { + jsonrpc: '2.0'; + id: RequestId; + result: { resultType: 'complete'; _meta: { [SUBSCRIPTION_ID_META_KEY]: RequestId } }; + }[] { + const out: { + jsonrpc: '2.0'; + id: RequestId; + result: { resultType: 'complete'; _meta: { [SUBSCRIPTION_ID_META_KEY]: RequestId } }; + }[] = []; + for (const id of this._subs.keys()) { + out.push({ jsonrpc: '2.0', id, result: { resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: id } } }); + } + this._subs.clear(); + return out; + } +} + +function notificationToServerEvent(method: string, uri: string | undefined): import('./serverEventBus.js').ServerEvent { + switch (method) { + case 'notifications/tools/list_changed': { + return { kind: 'tools_list_changed' }; + } + case 'notifications/prompts/list_changed': { + return { kind: 'prompts_list_changed' }; + } + case 'notifications/resources/list_changed': { + return { kind: 'resources_list_changed' }; + } + default: { + return { kind: 'resource_updated', uri: uri ?? '' }; + } + } +} diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 95c2476f34..bd499efa59 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, @@ -7,6 +8,7 @@ import type { GetPromptResult, Icon, Implementation, + InputRequiredResult, ListPromptsResult, ListResourcesResult, ListToolsResult, @@ -28,10 +30,15 @@ import type { import { assertCompleteRequestPrompt, assertCompleteRequestResourceTemplate, + assertValidCacheHint, + attachCacheHintFallback, + isInputRequiredResult, normalizeRawShapeSchema, promptArgumentsFromStandardSchema, ProtocolError, ProtocolErrorCode, + ResourceNotFoundError, + scanXMcpHeaderDeclarations, standardSchemaToJsonSchema, UriTemplate, validateAndWarnToolName, @@ -68,6 +75,44 @@ export class McpServer { } = {}; private _registeredTools: { [name: string]: RegisteredTool } = {}; private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; + /** + * Per-tool JSON-converted `inputSchema`, memoized so the SEP-2243 + * registration-time scan and the pre-dispatch validation step share one + * conversion instead of paying it twice per request under the + * per-request-factory `createMcpHandler` model. + */ + private _toolInputSchemaJson: { [name: string]: Record } = {}; + + /** + * The JSON-serialized `inputSchema` of a registered tool, or `undefined` + * when no such tool is registered. Used by the HTTP entry's pre-dispatch + * SEP-2243 `Mcp-Param-*` validation step (which needs the same JSON Schema + * `tools/list` would emit, before dispatch reaches the handler). + * + * @internal + */ + toolInputSchemaJson(name: string): Record | undefined { + const tool = this._registeredTools[name]; + if (tool === undefined || !tool.enabled) return undefined; + if (Object.hasOwn(this._toolInputSchemaJson, name)) return this._toolInputSchemaJson[name]; + if (tool.inputSchema === undefined) return EMPTY_OBJECT_JSON_SCHEMA; + // Lazy path: the memo slot is unset because `registerTool`'s eager + // conversion threw (and was swallowed per its "warn, never throw" + // contract) or `update({paramsSchema})`/rename invalidated it. The + // pre-dispatch SEP-2243 caller must not turn that into a 500 for a + // `tools/call` whose body-authoritative dispatch would otherwise + // succeed — return `undefined` so validation is skipped and the + // conversion failure stays where it always surfaced (`tools/list`). + // A successful re-derive is memoized so the per-request-factory + // `createMcpHandler` model does not re-convert on every call. + try { + const json = standardSchemaToJsonSchema(tool.inputSchema, 'input'); + this._toolInputSchemaJson[name] = json; + return json; + } catch { + return undefined; + } + } constructor(serverInfo: Implementation, options?: ServerOptions) { this.server = new Server(serverInfo, options); @@ -150,6 +195,10 @@ export class McpServer { }; if (tool.outputSchema) { + // SEP-2106 legacy interop (non-object outputSchema roots wrapped in + // `{type:'object',properties:{result:},required:['result']}` toward + // 2025-era clients) lives in the 2025 wire codec's `encodeResult('tools/list', …)` + // — this handler is era-blind and emits the natural converted schema. toolDefinition.outputSchema = standardSchemaToJsonSchema(tool.outputSchema, 'output') as Tool['outputSchema']; } @@ -158,7 +207,7 @@ export class McpServer { }) ); - this.server.setRequestHandler('tools/call', async (request, ctx): Promise => { + this.server.setRequestHandler('tools/call', async (request, ctx): Promise => { const tool = this._registeredTools[request.params.name]; if (!tool) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool ${request.params.name} not found`); @@ -171,7 +220,13 @@ export class McpServer { const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); const result = await this.executeToolHandler(tool, args, ctx); await this.validateToolOutput(tool, result, request.params.name); - return result; + if (isInputRequiredResult(result)) return result; + // SEP-2106 result-side projection (the era-agnostic TextContent auto-append; the + // `{result:…}` wrap on the 2025 era) lives behind the wire codec's + // `projectCallToolResult`. The codec receives the SAME advertised JSON Schema + // `tools/list` emits (and that the codec's `encodeResult('tools/list', …)` may have + // wrapped) so the listing and the call cannot diverge. + return this.server.projectCallToolResult(result, tool.outputSchemaJson); } catch (error) { if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { throw error; // Return the error to the caller without wrapping in CallToolResult @@ -230,16 +285,26 @@ export class McpServer { /** * Validates tool output against the tool's output schema. */ - private async validateToolOutput(tool: RegisteredTool, result: CallToolResult, toolName: string): Promise { + private async validateToolOutput(tool: RegisteredTool, result: CallToolResult | InputRequiredResult, toolName: string): Promise { if (!tool.outputSchema) { return; } + // An input-required result is not the tool's final output: structured + // content is only required (and validated) on the completing result. + if (isInputRequiredResult(result)) { + return; + } + if (result.isError) { return; } - if (!result.structuredContent) { + // SEP-2106: `structuredContent` may legally be any JSON value including `null`, `0`, + // `false`, `""`. The presence check is therefore `=== undefined` (not falsy); when present, + // the value is ALWAYS validated against the output schema — a falsy value against an + // object-typed schema fails validation, so this is not a guard weakening. + if (result.structuredContent === undefined) { throw new ProtocolError( ProtocolErrorCode.InvalidParams, `Output validation error: Tool ${toolName} has an output schema but no structured content was provided` @@ -259,7 +324,11 @@ export class McpServer { /** * Executes a tool handler. */ - private async executeToolHandler(tool: RegisteredTool, args: unknown, ctx: ServerContext): Promise { + private async executeToolHandler( + tool: RegisteredTool, + args: unknown, + ctx: ServerContext + ): Promise { // Executor encapsulates handler invocation with proper types return tool.executor(args, ctx); } @@ -415,18 +484,24 @@ 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); } } - throw new ProtocolError(ProtocolErrorCode.ResourceNotFound, `Resource ${uri} not found`); + // Domain layer throws one neutral resource-not-found error; the + // era-aware encode seam (WireCodec.encodeErrorCode) selects the + // wire code (−32602 on every era). + throw new ResourceNotFoundError(request.params.uri); }); this._resourceHandlersInitialized = true; @@ -466,7 +541,7 @@ export class McpServer { }) ); - this.server.setRequestHandler('prompts/get', async (request, ctx): Promise => { + this.server.setRequestHandler('prompts/get', async (request, ctx): Promise => { const prompt = this._registeredPrompts[request.params.name]; if (!prompt) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${request.params.name} not found`); @@ -502,19 +577,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`); @@ -524,9 +616,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(); @@ -540,9 +635,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(); @@ -711,6 +809,33 @@ export class McpServer { // Validate tool name according to SEP specification validateAndWarnToolName(name); + // SEP-2243 registration-time declaration-validity check (additive: warn, + // never throw — clients enforce by exclusion, servers by header + // validation; a malformed declaration here should not block local + // development against a stdio client that ignores it). The conversion + // is memoized so the pre-dispatch validation step in `createMcpHandler` + // (and `toolInputSchemaJson()`) does not repeat it for the same tool. + // `standardSchemaToJsonSchema` can throw for schemas it cannot convert + // (e.g. a vendor without `~standard.jsonSchema`); the try/catch keeps + // the "warn, never throw" contract. + if (inputSchema !== undefined) { + try { + const json = standardSchemaToJsonSchema(inputSchema, 'input'); + this._toolInputSchemaJson[name] = json; + const scan = scanXMcpHeaderDeclarations(json); + if (!scan.valid) { + console.warn( + `[mcp-sdk] tool '${name}' carries an invalid x-mcp-header declaration and will be excluded by ` + + `conforming Streamable HTTP clients: ${scan.reason}` + ); + } + } catch { + // Conversion failure: leave the cache slot unset so the lazy + // path in `toolInputSchemaJson()` (and `tools/list`) surfaces + // the failure where it always has. + } + } + // Track current handler for executor regeneration let currentHandler = handler; @@ -719,6 +844,7 @@ export class McpServer { description, inputSchema, outputSchema, + outputSchemaJson: convertOutputSchemaJson(outputSchema), annotations, icons, execution, @@ -730,12 +856,27 @@ export class McpServer { enable: () => registeredTool.update({ enabled: true }), remove: () => registeredTool.update({ name: null }), update: updates => { + // The closure's `name` tracks the CURRENT registry key, not + // the original registration name — renaming reassigns it so + // subsequent paramsSchema/rename invalidations evict the live + // `_toolInputSchemaJson` slot rather than the original. if (updates.name !== undefined && updates.name !== name) { if (typeof updates.name === 'string') { validateAndWarnToolName(updates.name); } delete this._registeredTools[name]; - if (updates.name) this._registeredTools[updates.name] = registeredTool; + delete this._toolInputSchemaJson[name]; + if (updates.name) { + // The TARGET key may already be occupied by another + // tool (rename has no duplicate-name guard) — drop + // its memo too, otherwise `toolInputSchemaJson()` + // returns the displaced tool's converted schema and + // the SEP-2243 pre-dispatch validation runs against + // the wrong schema for this name. + delete this._toolInputSchemaJson[updates.name]; + this._registeredTools[updates.name] = registeredTool; + name = updates.name; + } } if (updates.title !== undefined) registeredTool.title = updates.title; if (updates.description !== undefined) registeredTool.description = updates.description; @@ -744,6 +885,7 @@ export class McpServer { let needsExecutorRegen = false; if (updates.paramsSchema !== undefined) { registeredTool.inputSchema = updates.paramsSchema; + delete this._toolInputSchemaJson[name]; needsExecutorRegen = true; } if (updates.callback !== undefined) { @@ -755,7 +897,10 @@ export class McpServer { registeredTool.executor = createToolExecutor(registeredTool.inputSchema, currentHandler); } - if (updates.outputSchema !== undefined) registeredTool.outputSchema = updates.outputSchema; + if (updates.outputSchema !== undefined) { + registeredTool.outputSchema = updates.outputSchema; + registeredTool.outputSchemaJson = convertOutputSchemaJson(updates.outputSchema); + } if (updates.annotations !== undefined) registeredTool.annotations = updates.annotations; if (updates.icons !== undefined) registeredTool.icons = updates.icons; if (updates._meta !== undefined) registeredTool._meta = updates._meta; @@ -1067,13 +1212,19 @@ export type InferRawShape = z.infer>; /** {@linkcode ToolCallback} variant used when `inputSchema` is a {@linkcode ZodRawShape}. */ export type LegacyToolCallback = Args extends ZodRawShape - ? (args: InferRawShape, ctx: ServerContext) => CallToolResult | Promise - : (ctx: ServerContext) => CallToolResult | Promise; + ? ( + args: InferRawShape, + ctx: ServerContext + ) => CallToolResult | InputRequiredResult | Promise + : (ctx: ServerContext) => CallToolResult | InputRequiredResult | Promise; /** {@linkcode PromptCallback} variant used when `argsSchema` is a {@linkcode ZodRawShape}. */ export type LegacyPromptCallback = Args extends ZodRawShape - ? (args: InferRawShape, ctx: ServerContext) => GetPromptResult | Promise - : (ctx: ServerContext) => GetPromptResult | Promise; + ? ( + args: InferRawShape, + ctx: ServerContext + ) => GetPromptResult | InputRequiredResult | Promise + : (ctx: ServerContext) => GetPromptResult | InputRequiredResult | Promise; export type BaseToolCallback< SendResultT extends Result, @@ -1087,7 +1238,7 @@ export type BaseToolCallback< * Callback for a tool handler registered with {@linkcode McpServer.registerTool}. */ export type ToolCallback = BaseToolCallback< - CallToolResult, + CallToolResult | InputRequiredResult, ServerContext, Args >; @@ -1100,13 +1251,22 @@ export type AnyToolHandler Promise; +type ToolExecutor = (args: unknown, ctx: ServerContext) => Promise; export type RegisteredTool = { title?: string; description?: string; inputSchema?: StandardSchemaWithJSON; outputSchema?: StandardSchemaWithJSON; + /** + * @hidden + * The converted JSON Schema of `outputSchema`, memoised at registration (and on + * `update({outputSchema})`) so the `tools/call` handler passes the SAME advertised schema + * `tools/list` emits to the wire codec's `projectCallToolResult` — the SEP-2106 `{result:…}` + * wrap predicate follows the schema's root, never the runtime value shape. `undefined` when + * no `outputSchema` is registered or its conversion threw (see {@link convertOutputSchemaJson}). + */ + outputSchemaJson?: Record; annotations?: ToolAnnotations; icons?: Icon[]; execution?: ToolExecution; @@ -1147,7 +1307,9 @@ function createToolExecutor( } // When no inputSchema, call with just ctx (the handler expects (ctx) signature) - const callback = handler as (ctx: ServerContext) => CallToolResult | Promise; + const callback = handler as ( + ctx: ServerContext + ) => CallToolResult | InputRequiredResult | Promise; return async (_args, ctx) => callback(ctx); } @@ -1156,6 +1318,21 @@ const EMPTY_OBJECT_JSON_SCHEMA = { properties: {} }; +/** + * Convert a registered `outputSchema` to JSON Schema, memoised on {@link RegisteredTool.outputSchemaJson} + * so `tools/call` passes the SAME advertised schema to the wire codec's `projectCallToolResult` that + * `tools/list` emits (and that the 2025 codec's `encodeResult('tools/list', …)` may wrap). A conversion + * failure yields `undefined` so the failure surfaces where it always has (`tools/list`). + */ +function convertOutputSchemaJson(outputSchema: StandardSchemaWithJSON | undefined): Record | undefined { + if (outputSchema === undefined) return undefined; + try { + return standardSchemaToJsonSchema(outputSchema, 'output'); + } catch { + return undefined; + } +} + /** * Additional, optional information for annotating a resource. */ @@ -1169,12 +1346,17 @@ export type ListResourcesCallback = (ctx: ServerContext) => ListResourcesResult /** * Callback to read a resource at a given URI. */ -export type ReadResourceCallback = (uri: URL, ctx: ServerContext) => ReadResourceResult | Promise; +export type ReadResourceCallback = ( + uri: URL, + ctx: ServerContext +) => ReadResourceResult | InputRequiredResult | Promise; 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; @@ -1197,12 +1379,14 @@ export type ReadResourceTemplateCallback = ( uri: URL, variables: Variables, ctx: ServerContext -) => ReadResourceResult | Promise; +) => ReadResourceResult | InputRequiredResult | Promise; 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; @@ -1219,16 +1403,22 @@ export type RegisteredResourceTemplate = { }; export type PromptCallback = Args extends StandardSchemaWithJSON - ? (args: StandardSchemaWithJSON.InferOutput, ctx: ServerContext) => GetPromptResult | Promise - : (ctx: ServerContext) => GetPromptResult | Promise; + ? ( + args: StandardSchemaWithJSON.InferOutput, + ctx: ServerContext + ) => GetPromptResult | InputRequiredResult | Promise + : (ctx: ServerContext) => GetPromptResult | InputRequiredResult | Promise; /** * Internal handler type that encapsulates parsing and callback invocation. * This allows type-safe handling without runtime type assertions. */ -type PromptHandler = (args: Record | undefined, ctx: ServerContext) => Promise; +type PromptHandler = (args: Record | undefined, ctx: ServerContext) => Promise; -type ToolCallbackInternal = (args: unknown, ctx: ServerContext) => CallToolResult | Promise; +type ToolCallbackInternal = ( + args: unknown, + ctx: ServerContext +) => CallToolResult | InputRequiredResult | Promise; export type RegisteredPrompt = { title?: string; @@ -1264,7 +1454,10 @@ function createPromptHandler( callback: PromptCallback ): PromptHandler { if (argsSchema) { - const typedCallback = callback as (args: unknown, ctx: ServerContext) => GetPromptResult | Promise; + const typedCallback = callback as ( + args: unknown, + ctx: ServerContext + ) => GetPromptResult | InputRequiredResult | Promise; return async (args, ctx) => { const parseResult = await validateStandardSchema(argsSchema, args); @@ -1274,7 +1467,9 @@ function createPromptHandler( return typedCallback(parseResult.data, ctx); }; } else { - const typedCallback = callback as (ctx: ServerContext) => GetPromptResult | Promise; + const typedCallback = callback as ( + ctx: ServerContext + ) => GetPromptResult | InputRequiredResult | Promise; return async (_args, ctx) => { return typedCallback(ctx); 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/requestStateCodec.ts b/packages/server/src/server/requestStateCodec.ts new file mode 100644 index 0000000000..5dee32d036 --- /dev/null +++ b/packages/server/src/server/requestStateCodec.ts @@ -0,0 +1,267 @@ +import type { ServerContext } from '@modelcontextprotocol/core'; + +/** + * Options for {@linkcode createRequestStateCodec}. + */ +export interface RequestStateCodecOptions { + /** + * The HMAC secret. A `string` value is UTF-8-encoded. MUST be at least + * 32 bytes (256 bits) long; a `RangeError` is thrown at + * construction otherwise. The same key must be available to every server + * instance that may receive an echoed `requestState` (so a per-process + * random key only works when one process serves every round of a flow). + */ + key: Uint8Array | string; + + /** + * How long a minted `requestState` stays valid, in seconds. An echoed + * value past its expiry is rejected by {@linkcode RequestStateCodec.verify}. + * Defaults to `600` (ten minutes). + */ + ttlSeconds?: number; + + /** + * Optional context binding. Called at mint time and again at verify time; + * a `requestState` minted under one binding value is rejected when echoed + * under a different one. Use this to bind state to the authenticated + * principal and/or the originating method (the spec's user-binding MUST + * for state that influences authorization), for example: + * + * ```ts + * bind: ctx => `${ctx.mcpReq.method}\0${ctx.http?.authInfo?.clientId ?? ''}` + * ``` + * + * The returned value is stored in the envelope as a domain-separated HMAC + * tag (keyed by the codec's `key`), not the raw string — so a principal + * identifier in the binding does not appear in the wire value the client + * holds. + * + * When configured, {@linkcode RequestStateCodec.mint} requires its `ctx` + * argument. + */ + bind?: (ctx: ServerContext) => string; +} + +/** + * The codec returned by {@linkcode createRequestStateCodec}: `mint` seals a + * JSON-serializable payload into the wire string a handler returns from + * `inputRequired({ requestState })`; `verify` is the function to drop into + * {@linkcode server/server.ServerOptions | ServerOptions}`.requestState.verify` + * (it throws on any failure, which the seam answers as the frozen `-32602`) + * AND the function a handler calls to read the payload back from + * `ctx.mcpReq.requestState` after the seam has run. + */ +export interface RequestStateCodec { + /** + * Seal `payload` into an opaque wire string. The result is what the + * handler returns from `inputRequired({ requestState })`. + * + * @param ctx The handler's context. Required when the codec was created + * with a {@linkcode RequestStateCodecOptions.bind | bind} + * callback; ignored otherwise. + */ + mint(payload: T, ctx?: ServerContext): Promise; + + /** + * Verify an echoed `requestState` and return the original payload. Throws + * on any failure (bad MAC, expired, bind mismatch, malformed). The thrown + * message is a fixed opaque reason code (`'malformed'` / `'mac'` / + * `'expired'` / `'bind'`) — never the decoded payload, the binding value, + * or any other context-derived field. + * + * Pass this directly as `ServerOptions.requestState.verify`. + */ + verify(state: string, ctx: ServerContext): Promise; +} + +const PREFIX = 'v1.'; + +// Runtime-neutral base64url (no padding) over raw bytes. `btoa`/`atob` are +// available in browsers, Cloudflare Workers, and Node 16+. +function bytesToBase64Url(bytes: Uint8Array): string { + let bin = ''; + for (const b of bytes) bin += String.fromCodePoint(b); + return btoa(bin).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); +} + +// Constant-time equality on the fixed-length base64url bind-tag string. Same +// guarantee as the body-MAC check (which uses `subtle.verify`): the +// per-character XOR accumulator does not vary its trip count with the position +// of the first mismatch. Length is fixed (22 chars for the 128-bit truncated +// tag), so the early length-check leaks nothing. +function constantTimeTagEqual(a: string, b: string): boolean { + if (a.length !== b.length) { + return false; + } + let r = 0; + for (let i = 0; i < a.length; i++) { + r |= a.codePointAt(i)! ^ b.codePointAt(i)!; + } + return r === 0; +} + +function base64UrlToBytes(s: string): Uint8Array { + const b64 = s.replaceAll('-', '+').replaceAll('_', '/'); + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.codePointAt(i)!; + return bytes; +} + +/** + * Create an opt-in HMAC-SHA256 codec for the multi-round-trip `requestState` + * (protocol revision 2026-07-28). + * + * `requestState` round-trips through the client and is attacker-controlled + * input on re-entry. The SDK applies no protection of its own; this helper is + * the convenience implementation of the spec's integrity MUST so authors don't + * hand-roll HMAC. Wire shape: + * + * "v1." b64url({"p":,"exp":,"b":?}) "." b64url(mac) + * + * where `bindTag` is `b64url(HMAC(key, "mcp.requestState.bind:" + bind(ctx))[:16])` + * — the binding value is never embedded raw. + * + * The codec is **signed, not encrypted**: the body is integrity-protected but + * the client can base64url-decode it and read the payload (`p`) in clear. Do + * not put secrets in the payload; use an AEAD construction if confidentiality + * is required. The handler reads its payload back by calling `verify` again on + * `ctx.mcpReq.requestState` after the seam has run — re-calling `verify` is + * the intended pattern (the seam already proved integrity; the second call is + * the decode). + * + * Verification is fail-closed and constant-time (WebCrypto `subtle.verify` for + * the body MAC; a fixed-length XOR-accumulator compare for the bind tag). + * See `examples/mrtr/server.ts` for a worked end-to-end example. + * + * Design comparison (mcp.d `secureRequestState`, the peer SDK's reference + * implementation): mcp.d additionally offers an AES-256-GCM encrypted mode and + * derives independent cipher / bind-HMAC sub-keys from the operator secret via + * HKDF-SHA256, with an auto-generated per-process ephemeral key when none is + * supplied. This codec deliberately ships only the signed mode and a single + * keyed HMAC (domain-separated by input prefix) — HKDF sub-key derivation and + * an encrypted mode are intentionally out of scope for the initial release. + */ +export function createRequestStateCodec(options: RequestStateCodecOptions): RequestStateCodec { + const subtle = globalThis.crypto?.subtle; + if (subtle === undefined) { + throw new TypeError( + 'createRequestStateCodec requires the Web Crypto API (globalThis.crypto.subtle); ' + + 'see https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/faq.md for the Node.js polyfill instructions' + ); + } + + // Snapshot the key bytes at construction. When `options.key` is a + // Uint8Array, holding the caller's reference would let a post-construction + // mutation (e.g. zeroing the secret for hygiene) silently change the key + // the lazy `importedKey()` reads on first mint/verify — a TOCTOU on the + // secret. The owned copy here is what every later read sees. + const keyBytes = typeof options.key === 'string' ? new TextEncoder().encode(options.key) : Uint8Array.from(options.key); + if (keyBytes.byteLength < 32) { + throw new RangeError(`createRequestStateCodec: key must be at least 32 bytes (got ${keyBytes.byteLength})`); + } + const ttlSeconds = options.ttlSeconds ?? 600; + if (!Number.isFinite(ttlSeconds)) { + // Infinity/NaN would serialize `exp` as JSON `null`, which then fails + // verify() with the misleading reason 'expired' on every round-trip — + // fail loudly at construction instead. + throw new RangeError('createRequestStateCodec: ttlSeconds must be a finite number'); + } + const bind = options.bind; + + // The CryptoKey is imported once (lazily) and reused for every mint/verify. + // `keyBytes` is already an owned standalone Uint8Array (snapshot above), so + // it satisfies WebCrypto's BufferSource requirement on every runtime. + let cryptoKey: ReturnType | undefined; + const importedKey = (): ReturnType => + (cryptoKey ??= subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify'])); + + const utf8 = new TextEncoder(); + + // Domain-separated HMAC tag of the binding value, truncated to 128 bits and + // base64url'd. The label keeps the bind-tag computation separate from the + // body MAC even though both use the same key (the body MAC's input is the + // versioned wire string `"v1." + base64url(...)` and can never start with + // this label). + const BIND_LABEL = 'mcp.requestState.bind:'; + const bindTag = async (value: string): Promise => { + const mac = new Uint8Array(await subtle.sign('HMAC', await importedKey(), utf8.encode(BIND_LABEL + value))); + return bytesToBase64Url(mac.slice(0, 16)); + }; + + return { + async mint(payload, ctx) { + const envelope: { p: T; exp: number; b?: string } = { + p: payload, + exp: Math.floor(Date.now() / 1000) + ttlSeconds + }; + if (bind !== undefined) { + if (ctx === undefined) { + throw new TypeError('createRequestStateCodec: mint() requires ctx when a bind callback is configured'); + } + envelope.b = await bindTag(bind(ctx)); + } + const body = bytesToBase64Url(utf8.encode(JSON.stringify(envelope))); + // The MAC covers `PREFIX + body` so the version tag is bound: a + // valid `body.mac` pair under `v1.` cannot be transplanted to a + // future `v2.` codec under the same key. + const mac = new Uint8Array(await subtle.sign('HMAC', await importedKey(), utf8.encode(PREFIX + body))); + return `${PREFIX}${body}.${bytesToBase64Url(mac)}`; + }, + + async verify(state, ctx) { + // Envelope shape: "v1." body "." mac. The MAC is checked FIRST so + // every other rejection reason is only reachable for a value we + // minted (or a peer with the key did). + const dot = state.lastIndexOf('.'); + if (!state.startsWith(PREFIX) || dot <= PREFIX.length) { + throw new Error('malformed'); + } + const body = state.slice(PREFIX.length, dot); + let macBytes: Uint8Array; + try { + macBytes = base64UrlToBytes(state.slice(dot + 1)); + } catch { + throw new Error('malformed'); + } + // SubtleCrypto.verify is constant-time by spec — no manual byte + // compare, no `timingSafeEqual` dependency. The MAC input mirrors + // mint: `PREFIX + body`, so the version tag is authenticated. + const ok = await subtle.verify('HMAC', await importedKey(), macBytes, utf8.encode(PREFIX + body)); + if (!ok) { + throw new Error('mac'); + } + // The body decoded after a good MAC is by construction the JSON we + // wrote; a parse failure here would indicate key compromise rather + // than tampering, but stays fail-closed regardless. + let envelope: { p: T; exp: number; b?: string }; + try { + envelope = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(base64UrlToBytes(body))) as { + p: T; + exp: number; + b?: string; + }; + } catch { + throw new Error('malformed'); + } + if (typeof envelope.exp !== 'number' || envelope.exp < Math.floor(Date.now() / 1000)) { + throw new Error('expired'); + } + if (bind !== undefined) { + const expected = await bindTag(bind(ctx)); + if (envelope.b === undefined || !constantTimeTagEqual(envelope.b, expected)) { + // Opaque reason only — never interpolate the expected/actual + // binding values (they may carry principal identifiers). + throw new Error('bind'); + } + } else if (envelope.b !== undefined) { + // Fail-closed on configuration drift: a token minted with a + // bind callback is rejected when verified by an instance that + // has none configured. Accepting it would silently drop the + // principal-binding guarantee. + throw new Error('bind'); + } + return envelope.p; + } + }; +} diff --git a/packages/server/src/server/serveStdio.ts b/packages/server/src/server/serveStdio.ts new file mode 100644 index 0000000000..a3fed9b4ee --- /dev/null +++ b/packages/server/src/server/serveStdio.ts @@ -0,0 +1,830 @@ +/** + * `serveStdio` — the stdio entry point for serving the 2026-07-28 protocol + * revision on a long-lived connection, with 2025-era serving as the default + * for clients that open with the `initialize` handshake. + * + * The entry owns the stdio transport and the era decision for the connection. + * It classifies the connection's opening exchange exactly once (using the + * same body-primary rules as the HTTP entry), constructs ONE server instance + * from the consumer's factory for the era the client opened with, pins that + * instance for the lifetime of the connection, and passes every later message + * straight through to it. No per-message era classification ever runs after + * the connection is pinned — exactly mirroring how `createMcpHandler` + * classifies an HTTP request before any instance exists. + * + * The opening exchange: + * + * - An `initialize` request (or any claim-less message) opens a 2025-era + * session: the factory builds a legacy instance and the connection is + * pinned to it (`legacy: 'serve'`, the default). With `legacy: 'reject'` + * the opening is answered with the unsupported-protocol-version error + * naming the supported modern revisions instead. + * - A request carrying a valid per-request `_meta` envelope naming a + * supported modern revision pins the connection to a modern instance + * (era-marked and given the modern-only handlers, exactly like the HTTP + * entry's modern path). + * - A `server/discover` probe is answered by an optimistically built modern + * instance but does NOT pin the connection yet: the spec's stdio + * backward-compatibility flow lets a client probe first and then either + * continue with modern requests (which pins the connection modern) or fall + * back to the `initialize` handshake when no mutually supported modern + * revision exists — in which case the probe instance is discarded and a + * fresh legacy instance serves the handshake. + * - Once the modern era is pinned, a later claim-less `initialize` is + * rejected with the unsupported-protocol-version error naming the supported + * revisions (the spec recommends naming them in any error returned to + * `initialize`, and forbids falling back once the modern era is confirmed). + * + * Every instance the factory produces serves exactly one era; the ambiguity + * of the opening exchange lives entirely in this entry. In the probe-fallback + * case the factory is called twice (once for the discarded probe instance, + * once for the legacy instance), so factories should be cheap and + * side-effect-free to construct — the same expectation `createMcpHandler` + * already sets for per-request construction. + * + * Hand-constructed servers connected directly to a `StdioServerTransport` + * are unaffected by this entry: they keep serving the 2025-era protocol they + * were written for. + */ +import type { + CancelledNotificationParams, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + MessageClassification, + MessageExtraInfo, + RequestId, + Transport, + TransportSendOptions +} from '@modelcontextprotocol/core'; +import { + carriesValidModernEnvelopeClaim, + envelopeClaimVersion, + hasEnvelopeClaim, + isJSONRPCErrorResponse, + isJSONRPCNotification, + isJSONRPCRequest, + isJSONRPCResultResponse, + modernOnlyStrictRejection, + ProtocolErrorCode, + requestMetaOf, + setNegotiatedProtocolVersion, + SUPPORTED_MODERN_PROTOCOL_VERSIONS, + UnsupportedProtocolVersionError, + validateEnvelopeMeta +} from '@modelcontextprotocol/core'; + +import type { McpServerFactory } from './createMcpHandler.js'; +import { DEFAULT_MAX_SUBSCRIPTIONS, StdioListenRouter } from './listenRouter.js'; +import { McpServer } from './mcp.js'; +import type { Server } from './server.js'; +import { installModernOnlyHandlers } from './server.js'; +import { StdioServerTransport } from './stdio.js'; + +/** Options for {@linkcode serveStdio}. */ +export interface ServeStdioOptions { + /** + * How a 2025-era opening (an `initialize` request, or any claim-less + * message) is handled: + * + * - `'serve'` (default) — the connection is pinned to a 2025-era instance + * from the same factory and served exactly as a hand-wired stdio server + * serves it today. + * - `'reject'` — the opening request is answered with the + * unsupported-protocol-version error naming the supported modern + * revisions (claim-less notifications are dropped); the connection + * stays open for a modern opening. + */ + legacy?: 'serve' | 'reject'; + /** + * Bring your own transport (for example a `StdioServerTransport` + * constructed over a Unix domain socket or TCP stream, per the stdio + * binding's custom-transport guidance). Defaults to a + * {@linkcode StdioServerTransport} over the current process's stdio. The + * entry owns the transport: it starts it, receives every inbound message, + * and closes it when the connection ends. + */ + transport?: Transport; + /** Callback for out-of-band errors (reporting only; it never alters what is written to the wire). */ + onerror?: (error: Error) => void; + /** + * Reject a new `subscriptions/listen` with `-32603` 'Subscription limit + * reached' (in-band, before the ack) when this many subscriptions are + * already open on this connection. + * @default 1024 + */ + maxSubscriptions?: number; +} + +/** The handle returned by {@linkcode serveStdio}. */ +export interface StdioServerHandle { + /** Tears the connection down: closes the pinned instance (if any) and the underlying transport. */ + close(): Promise; +} + +/* ------------------------------------------------------------------------ * + * Per-instance channel + * ------------------------------------------------------------------------ */ + +/** + * How long the probe-discard path waits for the probe instance to answer the + * requests it was delivered before closing it. The wait normally settles as + * soon as the DiscoverResult is handed to the wire (or immediately, when a + * delivered cancellation already settled the probe); the bound is a backstop + * so no edge can ever hold the connection's inbound pump indefinitely behind + * the discard. + */ +const DISCARD_ANSWER_TIMEOUT_MS = 3000; + +/** + * The transport a pinned instance is connected to: a thin channel that writes + * through to the entry-owned wire transport and receives the messages the + * entry forwards. The wire transport itself is never handed to an instance — + * that is what lets the entry discard an optimistic probe instance (close the + * channel) without tearing down the connection. + */ +class StdioConnectionChannel implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: T, extra?: MessageExtraInfo) => void; + + private _closed = false; + /** Request ids the entry delivered to the instance that the instance has not yet answered. */ + private readonly _pendingRequests = new Set(); + private _drainWaiters: Array<() => void> = []; + + constructor( + private readonly _wire: Transport, + private readonly _onInstanceClose: () => void, + /** + * Optional first-look on outbound messages. When set and returning + * `'handled'`, the channel does not write the message to the wire + * (the entry already wrote whatever was appropriate). Used by the + * modern-era listen router to fan a change notification out onto the + * active subscriptions instead of broadcasting it unsolicited. + */ + private readonly _outboundIntercept?: (message: JSONRPCMessage) => 'handled' | undefined + ) {} + + async start(): Promise { + // The entry already started the wire transport; connecting an + // instance to its channel must not start anything again. + } + + async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { + // The instance answered a delivered request: settle it whether or + // not the wire write below succeeds (write failures surface + // through the wire's own error reporting). + const { id } = message; + if (id !== undefined) { + this._settle(id); + } + } + if (this._closed) { + // A discarded or torn-down instance has nowhere to write; late + // sends are dropped. + return; + } + if (this._outboundIntercept?.(message) === 'handled') { + return; + } + return this._wire.send(message, options); + } + + setProtocolVersion = (version: string): void => { + this._wire.setProtocolVersion?.(version); + }; + + /** Forwards one inbound message to the connected instance. */ + deliver(message: JSONRPCMessage, extra?: MessageExtraInfo): void { + if (this._closed) { + return; + } + if (isJSONRPCRequest(message)) { + this._pendingRequests.add(message.id); + } else if (isJSONRPCNotification(message) && message.method === 'notifications/cancelled') { + // By protocol contract a cancelled request may legitimately go + // unanswered (the instance aborts the in-flight handler and writes + // nothing for it), so a delivered cancellation settles the request + // it names: nothing should keep waiting for an answer that may + // never come. Non-cancelled requests still settle only when their + // answer is handed to the wire. + const cancelledId = (message.params as CancelledNotificationParams | undefined)?.requestId; + if (cancelledId !== undefined) { + this._settle(cancelledId); + } + } + this.onmessage?.(message, extra); + } + + /** + * Resolves once every request delivered to the instance has been answered + * through {@linkcode send}, settled by a delivered cancellation, or the + * channel has been closed and nothing further can be answered. The wait is + * bounded by `timeoutMs` as a backstop so no edge can hold the caller + * indefinitely; resolves `false` only when the bound elapsed with requests + * still unanswered. Used by the probe-discard path so a probe request the + * entry accepted is never silently dropped. + */ + async whenRequestsAnswered(timeoutMs: number): Promise { + if (this._closed || this._pendingRequests.size === 0) { + return true; + } + return await new Promise(resolve => { + const waiter = (): void => { + clearTimeout(timer); + resolve(true); + }; + const timer = setTimeout(() => { + this._drainWaiters = this._drainWaiters.filter(pending => pending !== waiter); + resolve(false); + }, timeoutMs); + this._drainWaiters.push(waiter); + }); + } + + async close(): Promise { + if (this._closed) { + return; + } + this._closed = true; + // Nothing further can be answered through a closed channel; release + // anyone waiting on in-flight answers. + this._pendingRequests.clear(); + this._releaseDrainWaiters(); + try { + this._onInstanceClose(); + } finally { + this.onclose?.(); + } + } + + private _settle(id: RequestId): void { + this._pendingRequests.delete(id); + if (this._pendingRequests.size === 0) { + this._releaseDrainWaiters(); + } + } + + private _releaseDrainWaiters(): void { + const waiters = this._drainWaiters; + this._drainWaiters = []; + for (const waiter of waiters) { + waiter(); + } + } +} + +/* ------------------------------------------------------------------------ * + * Opening-exchange classification + * ------------------------------------------------------------------------ */ + +interface EnvelopeIssue { + key: string; + problem: string; +} + +type OpeningClassification = + /** A 2025-era opening: `initialize`, or any message without an envelope claim. */ + | { kind: 'legacy'; reason: 'initialize' | 'no-claim'; requestedVersion?: string } + /** A valid envelope claim naming a modern revision this entry serves. */ + | { kind: 'modern'; revision: string; classification: MessageClassification } + /** A present envelope claim whose envelope is malformed. */ + | { kind: 'invalid-envelope'; issue: EnvelopeIssue } + /** A valid envelope claim naming a revision this entry does not serve (unknown future or 2025-era). */ + | { kind: 'unsupported-revision'; requested: string }; + +/** + * Classifies one message of the opening exchange with the same body-primary + * rules the HTTP entry applies per request: `initialize` is the legacy + * handshake unless it carries a valid modern envelope claim; a present claim + * is validated (never silently ignored); a claim-less message is 2025-era + * traffic. There is no header layer on stdio, so the body is the only signal. + */ +function classifyOpeningMessage(message: JSONRPCRequest | JSONRPCNotification): OpeningClassification { + const params = message.params; + + if (message.method === 'initialize' && !carriesValidModernEnvelopeClaim(params)) { + const requestedVersion = + params !== null && typeof params === 'object' && typeof (params as { protocolVersion?: unknown }).protocolVersion === 'string' + ? ((params as { protocolVersion: string }).protocolVersion as string) + : undefined; + return { kind: 'legacy', reason: 'initialize', ...(requestedVersion !== undefined && { requestedVersion }) }; + } + + if (!hasEnvelopeClaim(params)) { + return { kind: 'legacy', reason: 'no-claim' }; + } + + // A present claim is validated, never silently ignored — a malformed + // envelope behind the claim is an invalid-params answer, not a fall back + // to legacy serving (mirrors the HTTP entry's envelope rung). + const meta = requestMetaOf(params); + const issues = meta === undefined ? [] : validateEnvelopeMeta(meta); + const firstIssue = issues[0]; + if (firstIssue !== undefined) { + return { kind: 'invalid-envelope', issue: firstIssue }; + } + + const claimedVersion = envelopeClaimVersion(params); + if (claimedVersion === undefined || !SUPPORTED_MODERN_PROTOCOL_VERSIONS.includes(claimedVersion)) { + // The claim names a revision this entry does not serve (an unknown + // future revision, or a 2025-era revision delivered via the envelope + // mechanism) — answered like the HTTP entry's modern path. + return { kind: 'unsupported-revision', requested: claimedVersion ?? 'unknown' }; + } + + return { kind: 'modern', revision: claimedVersion, classification: { era: 'modern', revision: claimedVersion } }; +} + +/* ------------------------------------------------------------------------ * + * The entry + * ------------------------------------------------------------------------ */ + +interface ConnectedInstance { + product: McpServer | Server; + channel: StdioConnectionChannel; +} + +type EntryState = + /** Waiting for the connection's opening message. */ + | { phase: 'opening' } + /** A `server/discover` probe was answered; the era is not pinned yet. */ + | { phase: 'probe'; instance: ConnectedInstance } + /** The connection is pinned to one instance serving one era. */ + | { phase: 'pinned'; era: 'legacy' | 'modern'; instance: ConnectedInstance } + | { phase: 'closed' }; + +/** + * Serves MCP over stdio from a server factory, owning the era decision for + * the connection: the opening exchange selects the era, ONE instance from the + * factory is pinned for the connection lifetime, and everything after passes + * straight through to it. See the module documentation for the opening rules. + * + * ```ts + * import { serveStdio } from '@modelcontextprotocol/server/stdio'; + * + * serveStdio(() => { + * const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + * // register tools/resources/prompts once — the same factory serves both eras + * return server; + * }); + * ``` + */ +export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions = {}): StdioServerHandle { + const legacyMode = options.legacy ?? 'serve'; + const wire = options.transport ?? new StdioServerTransport(); + + let state: EntryState = { phase: 'opening' }; + /** Channel currently being discarded (its close must not tear the connection down). */ + let discarding: StdioConnectionChannel | undefined; + let closing = false; + + /** + * Whether the connection has been torn down (`handle.close()` or the wire + * closing). The opening arms re-check this after every await: a close can + * race factory construction, and the continuation must neither resurrect + * the connection state nor keep a late-resolved instance around. + */ + const isTornDown = (): boolean => closing || state.phase === 'closed'; + + const reportError = (error: Error) => { + try { + options.onerror?.(error); + } catch { + // Reporting must never affect the wire. + } + }; + + const writeErrorResponse = (id: RequestId, code: number, message: string, data?: unknown): Promise => + wire + .send({ jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined && { data }) } }) + .catch(error => reportError(toError(error))); + + /** + * Entry-handled `subscriptions/listen` for this connection: holds the + * active subscriptions, serves inbound listen / cancelled-of-listen + * before the pinned instance is consulted, and rewrites the instance's + * outbound change notifications onto the active subscriptions. Only + * consulted on a modern-pinned connection — on a legacy connection + * change notifications pass straight through (the 2025 unsolicited + * delivery model is unchanged). + */ + const listenRouter = new StdioListenRouter(options.maxSubscriptions ?? DEFAULT_MAX_SUBSCRIPTIONS); + + /** Outbound intercept installed on a modern instance's channel. */ + const modernOutboundIntercept = (message: JSONRPCMessage): 'handled' | undefined => { + if (!isJSONRPCNotification(message)) return undefined; + const routed = listenRouter.routeOutbound(message); + if (routed === 'passthrough') return undefined; + // A subscription-gated change notification on the modern era: one + // stamped copy per subscription that opted in (an empty array means + // it is dropped — the modern era never delivers an un-requested + // change type unsolicited). Nothing else from the instance is + // affected. + for (const stamped of routed) { + void wire.send({ jsonrpc: '2.0', ...stamped }).catch(error => reportError(toError(error))); + } + return 'handled'; + }; + + /** + * Entry-handled inbound listen routing for a modern-pinned connection. + * Returns `true` when the message was served at the entry and must NOT + * be delivered to the pinned instance. + */ + const tryServeListen = async (message: JSONRPCMessage): Promise => { + if (isJSONRPCRequest(message) && message.method === 'subscriptions/listen') { + // Entry-handled listen is its own request-handling subsystem; it + // applies the same per-request envelope rung the instance's + // `_onrequest` would (method-existence is N/A here — the entry + // recognized the method — so envelope validation is the first + // applicable rung) and the same supported-revision check the + // opening classifier and the HTTP entry apply per request. Reuses + // the same validators the opening classifier uses. + const meta = requestMetaOf(message.params); + const issue = hasEnvelopeClaim(message.params) + ? (meta === undefined ? [] : validateEnvelopeMeta(meta))[0] + : { key: '_meta', problem: 'the per-request envelope is required on protocol revision 2026-07-28' }; + const claimedVersion = envelopeClaimVersion(message.params); + let reply; + if (issue !== undefined) { + reply = { + jsonrpc: '2.0' as const, + id: message.id, + error: { code: -32_602, message: `Invalid _meta envelope: ${issue.key}: ${issue.problem}` } + }; + } else if (claimedVersion === undefined || !SUPPORTED_MODERN_PROTOCOL_VERSIONS.includes(claimedVersion)) { + const error = new UnsupportedProtocolVersionError({ + supported: [...SUPPORTED_MODERN_PROTOCOL_VERSIONS], + requested: claimedVersion ?? 'unknown' + }); + reply = { jsonrpc: '2.0' as const, id: message.id, error: { code: error.code, message: error.message, data: error.data } }; + } else { + reply = listenRouter.serve(message); + } + await wire + .send('error' in reply ? reply : { jsonrpc: '2.0', method: reply.method, params: reply.params }) + .catch(error => reportError(toError(error))); + return true; + } + if (isJSONRPCNotification(message) && message.method === 'notifications/cancelled') { + const cancelledId = (message.params as CancelledNotificationParams | undefined)?.requestId; + // Inbound cancel of a parked listen: tear the subscription down + // and DO NOT deliver to the instance (it never saw the listen + // request). After this point nothing further is delivered for + // that subscription id (post-cancel hardening). + if (cancelledId !== undefined && listenRouter.cancel(cancelledId)) { + return true; + } + } + return false; + }; + + /** Answers a 2025-era request the entry will not serve (the modern-only rejection cells). */ + const answerLegacyRejection = ( + request: JSONRPCRequest, + reason: 'initialize' | 'no-claim', + requestedVersion?: string + ): Promise => { + const rejection = modernOnlyStrictRejection( + { kind: 'legacy', reason, ...(requestedVersion !== undefined && { requestedVersion }) }, + SUPPORTED_MODERN_PROTOCOL_VERSIONS + ); + if (rejection === undefined) { + return Promise.resolve(); + } + reportError(new Error(`Rejected 2025-era request on a modern-only stdio connection (${rejection.cell}): ${rejection.message}`)); + return writeErrorResponse(request.id, rejection.code, rejection.message, rejection.data); + }; + + const onInstanceClosed = (channel: StdioConnectionChannel) => { + if (closing || channel === discarding) { + return; + } + // The pinned (or probe) instance was closed from the instance side: + // the connection is over. + void closeAll(); + }; + + const connectInstance = async (era: 'legacy' | 'modern', revision?: string): Promise => { + const product = await factory({ era }); + const server = product instanceof McpServer ? product.server : product; + if (era === 'modern') { + // Era-write at instance binding, then modern-only handler + // installation — the same helpers the HTTP entry's modern path + // uses, before the instance is connected. + setNegotiatedProtocolVersion(server, revision); + installModernOnlyHandlers(server, SUPPORTED_MODERN_PROTOCOL_VERSIONS); + // The listen router was created before this instance existed; now + // that capabilities are known, hand them over so the acknowledged + // filter is narrowed against what the server actually advertises. + listenRouter.setServerCapabilities(server.getCapabilities()); + } + const channel: StdioConnectionChannel = new StdioConnectionChannel( + wire, + () => onInstanceClosed(channel), + era === 'modern' ? modernOutboundIntercept : undefined + ); + await product.connect(channel); + return { product, channel }; + }; + + /** Closes an instance whose factory resolved only after the connection was torn down. */ + const disposeLateInstance = (instance: ConnectedInstance): Promise => + instance.product.close().catch(error => reportError(toError(error))); + + const discardProbeInstance = async (instance: ConnectedInstance): Promise => { + // The probe instance served only the discover exchange; closing its + // channel must not tear down the connection the fallback is about to + // continue on. + discarding = instance.channel; + try { + // A probe request the entry accepted must never go silently + // unanswered: a client may pipeline its fallback `initialize` + // straight behind `server/discover` without waiting, and closing + // the instance aborts whatever it still has in flight. Let the + // in-flight DiscoverResult reach the wire before the instance is + // closed; the probe instance only ever receives `server/discover`, + // whose entry-installed handler always answers promptly. A probe + // the client cancelled is already settled by the delivered + // cancellation (a cancelled request may go unanswered), and the + // wait is bounded as a backstop so nothing can wedge the + // connection's pump behind the discard. + const answered = await instance.channel.whenRequestsAnswered(DISCARD_ANSWER_TIMEOUT_MS); + if (!answered) { + reportError( + new Error( + `Discarded the probe instance with requests still unanswered after ${DISCARD_ANSWER_TIMEOUT_MS}ms; continuing with the fallback` + ) + ); + } + await instance.product.close(); + } catch (error) { + reportError(toError(error)); + } finally { + discarding = undefined; + } + }; + + const processMessage = async (message: JSONRPCMessage): Promise => { + if (state.phase === 'closed') { + return; + } + + if (state.phase === 'pinned') { + if ( + state.era === 'modern' && + isJSONRPCRequest(message) && + message.method === 'initialize' && + !carriesValidModernEnvelopeClaim(message.params) + ) { + // The modern era is confirmed for this connection; a late + // legacy handshake is answered with the version error naming + // the supported revisions (the specification recommends + // naming them in any error returned to `initialize`, and + // rules out falling back once the modern era is confirmed). + const requestedVersion = + message.params !== null && + typeof message.params === 'object' && + typeof (message.params as { protocolVersion?: unknown }).protocolVersion === 'string' + ? ((message.params as { protocolVersion: string }).protocolVersion as string) + : undefined; + await answerLegacyRejection(message, 'initialize', requestedVersion); + return; + } + if (state.era === 'modern' && (await tryServeListen(message))) { + return; + } + state.instance.channel.deliver(message); + return; + } + + // Negotiation window ('opening' | 'probe'). + if (!isJSONRPCRequest(message) && !isJSONRPCNotification(message)) { + // A JSON-RPC response before any era is pinned: nothing has been + // asked of the client yet, so there is nothing it can answer. + reportError(new Error('Discarded a JSON-RPC response received before the connection negotiated an era')); + return; + } + + const opening = classifyOpeningMessage(message); + switch (opening.kind) { + case 'invalid-envelope': { + const detail = `Invalid _meta envelope for protocol revision 2026-07-28: ${opening.issue.key}: ${opening.issue.problem}`; + if (isJSONRPCRequest(message)) { + await writeErrorResponse(message.id, ProtocolErrorCode.InvalidParams, detail, { envelope: opening.issue }); + } else { + reportError(new Error(`Discarded a notification with a malformed envelope: ${detail}`)); + } + return; + } + case 'unsupported-revision': { + if (isJSONRPCRequest(message)) { + const error = new UnsupportedProtocolVersionError({ + supported: [...SUPPORTED_MODERN_PROTOCOL_VERSIONS], + requested: opening.requested + }); + reportError(error); + await writeErrorResponse(message.id, error.code, error.message, error.data); + } else { + reportError(new Error(`Discarded a notification claiming unsupported protocol revision ${opening.requested}`)); + } + return; + } + case 'modern': { + if (isJSONRPCRequest(message) && message.method === 'server/discover') { + if (state.phase === 'probe') { + // A repeated probe is answered by the same optimistic + // instance and the negotiation window stays open: only + // a non-discover enveloped request commits the + // connection to the modern era, so a later fallback + // `initialize` is still served by a fresh legacy + // instance. + state.instance.channel.deliver(message, { classification: opening.classification }); + return; + } + // Probe: answer from an optimistically built modern + // instance so the advertisement reflects the real server + // definition, but do not pin the connection yet — the + // client may still fall back to `initialize` when it + // shares no modern revision with the advertisement. + const instance = await connectInstance('modern', opening.revision); + if (isTornDown()) { + // The connection was torn down while the factory was + // building the probe instance: dispose of it and stay + // closed instead of resurrecting the negotiation + // window; nothing is delivered or answered. + await disposeLateInstance(instance); + return; + } + state = { phase: 'probe', instance }; + instance.channel.deliver(message, { classification: opening.classification }); + return; + } + if (state.phase === 'probe') { + if (isJSONRPCNotification(message)) { + // An enveloped notification during the negotiation + // window (for example a notifications/cancelled for + // the probe itself) is delivered to the probe instance + // without committing the era: only a non-discover + // enveloped request pins the connection, so a later + // fallback `initialize` is still served by a fresh + // legacy instance. + state.instance.channel.deliver(message, { classification: opening.classification }); + return; + } + // The probe was followed by a modern request: the client + // committed to the modern era — pin the probe instance. + state = { phase: 'pinned', era: 'modern', instance: state.instance }; + } else { + const instance = await connectInstance('modern', opening.revision); + if (isTornDown()) { + // Closed while the factory was building the modern + // instance: dispose of it and stay closed. + await disposeLateInstance(instance); + return; + } + state = { phase: 'pinned', era: 'modern', instance }; + } + if (await tryServeListen(message)) { + return; + } + state.instance.channel.deliver(message, { classification: opening.classification }); + return; + } + case 'legacy': { + if (legacyMode === 'reject') { + if (isJSONRPCRequest(message)) { + await answerLegacyRejection(message, opening.reason, opening.requestedVersion); + } + // Claim-less notifications are accepted and dropped (the + // stdio analog of the HTTP entry's 202-and-drop); the + // connection stays open for a modern opening. + return; + } + if (state.phase === 'probe') { + // Probe-then-fallback: the client probed, found no + // mutually supported modern revision, and fell back to + // the 2025 handshake on the same connection. The probe + // instance is discarded; a fresh legacy instance serves + // the handshake. + await discardProbeInstance(state.instance); + if (isTornDown()) { + // Closed while the probe was being discarded: stay closed. + return; + } + state = { phase: 'opening' }; + } + const instance = await connectInstance('legacy'); + if (isTornDown()) { + // Closed while the factory was building the legacy + // instance: dispose of it and stay closed. + await disposeLateInstance(instance); + return; + } + state = { phase: 'pinned', era: 'legacy', instance }; + state.instance.channel.deliver(message); + return; + } + } + }; + + // Inbound messages are processed strictly in arrival order: the queue + // absorbs anything that arrives while the opening exchange is still being + // decided (factory construction and instance connection are async). + const queue: JSONRPCMessage[] = []; + let pumping = false; + const pump = async (): Promise => { + if (pumping) { + return; + } + pumping = true; + try { + while (queue.length > 0) { + const message = queue.shift()!; + try { + await processMessage(message); + } catch (error) { + // Every arm of processMessage that answers a request does + // so through writeErrorResponse (which never throws — wire + // failures are routed to onerror) and returns right after, + // so an error escaping to here means the request was never + // answered. Answer it now: a throwing factory or a failed + // connect during the opening exchange must not leave the + // client's request hanging (the stdio analog of the HTTP + // entry's internal-server-error response). Notifications + // carry no id to answer and are only reported. + if (isJSONRPCRequest(message)) { + await writeErrorResponse(message.id, ProtocolErrorCode.InternalError, 'Internal server error'); + } + reportError(toError(error)); + } + } + } finally { + pumping = false; + } + }; + + const closeAll = async (): Promise => { + if (closing || state.phase === 'closed') { + return; + } + closing = true; + const current = state; + state = { phase: 'closed' }; + // Stdio server-side graceful teardown: emit the empty + // `subscriptions/listen` JSON-RPC result for every active subscription + // (the spec's graceful-close signal — `SubscriptionsListenResult`) + // before the wire is closed, so the client distinguishes graceful end + // from a transport drop. + for (const result of listenRouter.teardownAll()) { + await wire.send(result).catch(error => reportError(toError(error))); + } + if (current.phase === 'probe' || current.phase === 'pinned') { + await current.instance.product.close().catch(error => reportError(toError(error))); + } + await wire.close().catch(error => reportError(toError(error))); + }; + + wire.onmessage = (message: JSONRPCMessage) => { + queue.push(message); + void pump(); + }; + wire.onerror = error => { + reportError(error); + if (state.phase === 'probe' || state.phase === 'pinned') { + state.instance.channel.onerror?.(error); + } + }; + wire.onclose = () => { + if (closing || state.phase === 'closed') { + return; + } + closing = true; + const current = state; + state = { phase: 'closed' }; + if (current.phase === 'probe' || current.phase === 'pinned') { + void current.instance.product.close().catch(error => reportError(toError(error))); + } + }; + + const started = wire.start().catch(error => { + reportError(toError(error)); + throw error; + }); + // Surface a failed start through onerror (above); close() still resolves. + started.catch(() => {}); + + return { + close: async () => { + await started.catch(() => {}); + await closeAll(); + } + }; +} + +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index f96d8ec1bc..4142b789f6 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -1,14 +1,19 @@ import type { BaseContext, + CacheableResultMethod, + CacheHint, + CallToolResult, ClientCapabilities, CreateMessageRequest, CreateMessageRequestParamsBase, CreateMessageRequestParamsWithTools, CreateMessageResult, CreateMessageResultWithTools, + DiscoverResult, ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, + EmptyResult, Implementation, InitializeRequest, InitializeResult, @@ -16,6 +21,7 @@ import type { JsonSchemaType, jsonSchemaValidator, ListRootsRequest, + ListRootsResult, LoggingLevel, LoggingMessageNotification, MessageExtraInfo, @@ -32,25 +38,37 @@ import type { ToolUseContent } from '@modelcontextprotocol/core'; import { - CallToolRequestSchema, - CallToolResultSchema, - CreateMessageResultSchema, - CreateMessageResultWithToolsSchema, - ElicitResultSchema, - EmptyResultSchema, + assertValidCacheHint, + attachCacheHintFallback, + CLIENT_CAPABILITIES_META_KEY, + codecForVersion, + isInputRequiredResult, + isModernProtocolVersion, LATEST_PROTOCOL_VERSION, - ListRootsResultSchema, + legacyProtocolVersions, + LOG_LEVEL_META_KEY, LoggingLevelSchema, mergeCapabilities, + missingClientCapabilities, + MissingRequiredClientCapabilityError, + modernProtocolVersions, parseSchema, Protocol, ProtocolError, ProtocolErrorCode, + requiredClientCapabilitiesForInputRequest, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; +/** + * The request methods whose 2026-07-28 result vocabulary includes + * `input_required` (the multi round-trip methods). Returning an + * input-required result from any other handler is a server bug. + */ +const INPUT_REQUIRED_CAPABLE_METHODS: ReadonlySet = new Set(['tools/call', 'prompts/get', 'resources/read']); + export type ServerOptions = ProtocolOptions & { /** * Capabilities to advertise as being supported by this server. @@ -78,8 +96,111 @@ export type ServerOptions = ProtocolOptions & { * @default Runtime-selected validator (AJV-backed on Node.js, `@cfworker/json-schema`-backed on browser/workerd runtimes) */ jsonSchemaValidator?: jsonSchemaValidator; + + /** + * Cache hints for the cacheable results of the 2026-07-28 protocol + * revision (`ttlMs` / `cacheScope`), keyed by operation. The 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>; + + /** + * Multi-round-trip `requestState` integrity hook (protocol revision + * 2026-07-28). + */ + requestState?: { + /** + * Called on every re-entered multi-round-trip request that carries a + * `requestState` (i.e. whenever `ctx.mcpReq.requestState` is present), + * BEFORE the handler runs. Throw or reject to refuse the request: the + * seam answers with a wire-level `-32602` Invalid Params error whose + * message is frozen to `"Invalid or expired requestState"` and whose + * `data.reason` is `'invalid_request_state'` — the thrown reason is + * surfaced via the server's `onerror` callback only and never reaches + * the wire. + * + * This is the place to put HMAC or AEAD verification of + * `requestState`. The spec MUST for integrity-protecting state that + * influences authorization, resource access, or business logic is on + * the server author (basic/patterns/mrtr, server requirements 4–5); + * the SDK provides NO default verification — + * {@linkcode server/requestStateCodec.createRequestStateCodec | createRequestStateCodec} + * is the SDK-provided HMAC helper whose `verify` drops in here + * directly. Leaving this option + * unconfigured keeps today's behavior — `ctx.mcpReq.requestState` is + * passed through raw and MUST be treated as attacker-controlled + * input. + * + * The return value is ignored (the seam awaits-and-discards); the + * hook signature accepts any return so a verifier that also yields + * the decoded payload — as + * {@linkcode server/requestStateCodec.RequestStateCodec | RequestStateCodec}`.verify` + * does — is directly assignable. + */ + verify?: (state: string, ctx: ServerContext) => unknown | Promise; + }; }; +/* + * Package-internal hooks for the 2026-07-28 serving entries (the per-request + * HTTP entry `createMcpHandler` and the connection-pinned stdio entry + * `serveStdio`). + * + * The connection-scoped client-identity fields and the modern-only handler set are + * private to `Server`; the serving entries in this package need to write/install + * them on the fresh instance they get 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 +211,31 @@ 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 _cacheHints?: ServerOptions['cacheHints']; + private _requestStateVerify?: (state: string, ctx: ServerContext) => unknown | Promise; /** * Callback for when initialization has fully completed (i.e., the client has sent an `notifications/initialized` notification). @@ -111,10 +253,31 @@ export class Server extends Protocol { this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._instructions = options?.instructions; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); + this._requestStateVerify = options?.requestState?.verify; + + // 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?.()); + // server/discover is installed only when the supported-versions list + // carries a modern revision: a legacy-only server keeps answering -32601. + // A hand-constructed instance is never era-bound, so the handler stays + // unreachable behind the era gate until a serving entry (createMcpHandler, + // serveStdio) marks the instance as serving the 2026-07-28 era. + if (modernProtocolVersions(this._supportedProtocolVersions).length > 0) { + this.setRequestHandler('server/discover', () => this._ondiscover()); + } + if (this._capabilities.logging) { this._registerLoggingHandler(); } @@ -150,7 +313,39 @@ export class Server extends Protocol { // 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 }), + log: (level, data, logger) => { + if (!this._capabilities.logging) { + return Promise.resolve(); + } + // Level filter: on a 2026-era request the client declares its + // threshold per request via the `_meta.logLevel` envelope key + // (the modern equivalent of `logging/setLevel`, which is not a + // request method on that revision). The spec at 2026-07-28 + // says an absent key means the server MUST NOT send + // `notifications/message` for the request — so an absent key + // suppresses, it does not mean "send everything". On + // 2025-era connections the session-scoped level set via + // `logging/setLevel` applies exactly as before (an absent + // session level there continues to mean no filter). + let threshold: LoggingLevel | undefined; + if (this._servedModernEra()) { + threshold = ctx.mcpReq.envelope?.[LOG_LEVEL_META_KEY] as LoggingLevel | undefined; + if (threshold === undefined) { + return Promise.resolve(); + } + } else { + threshold = this._loggingLevels.get(ctx.sessionId) ?? this._loggingLevels.get(undefined); + } + if (threshold !== undefined && this.LOG_LEVEL_SEVERITY.get(level)! < this.LOG_LEVEL_SEVERITY.get(threshold)!) { + return Promise.resolve(); + } + // Emit request-related (like progress and `ctx.mcpReq.notify`) + // so the notification rides the in-flight exchange. Without the + // related-request stamp, per-request hosting (`createMcpHandler`, + // either era) silently drops the message because it has no + // session-wide stream to deliver it on. + return ctx.mcpReq.notify({ method: 'notifications/message', params: { level, data, logger } }); + }, elicitInput: (params, options) => this.elicitInput(params, options), requestSampling: (params, options) => this.createMessage(params, options) }, @@ -195,36 +390,273 @@ export class Server extends Protocol { /** * Enforces server-side validation for `tools/call` results regardless of how the - * handler was registered. + * handler was registered, 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, and owns the multi-round-trip + * seam: on the methods whose 2026-07-28 result vocabulary includes + * `input_required` (`tools/call`, `prompts/get`, `resources/read`) an + * input-required return skips result-schema validation and is checked + * against the served era, the at-least-one rule, and the request's own + * declared client capabilities; on every other method an input-required + * return is a server bug and fails loudly. 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]; + const isInputRequiredCapable = INPUT_REQUIRED_CAPABLE_METHODS.has(method); + if (cacheHint === undefined && !isInputRequiredCapable) { + // Server-bug guard: an input-required return from a method + // whose result vocabulary does not include it is never + // mis-typed onto the wire. + return async (request, ctx) => { + const result = await handler(request, ctx); + if (isInputRequiredResult(result)) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned an input-required result, but only tools/call, prompts/get and ` + + `resources/read support input_required (protocol revision 2026-07-28)` + ); + } + return result; + }; + } + return async (request, ctx) => { + const result = isInputRequiredCapable + ? await this._invokeInputRequiredCapableHandler(method, handler, request, ctx) + : await handler(request, ctx); + if (isInputRequiredResult(result)) { + if (!isInputRequiredCapable) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned an input-required result, but only tools/call, prompts/get and ` + + `resources/read support input_required (protocol revision 2026-07-28)` + ); + } + // Never cache-stamped (the encode contract skips + // non-complete results); the hint is not attached. + return result; + } + return cacheHint === undefined ? result : attachCacheHintFallback(result, cacheHint); + }; } return async (request, ctx) => { - const validatedRequest = parseSchema(CallToolRequestSchema, request); - if (!validatedRequest.success) { - const errorMessage = - validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid tools/call request: ${errorMessage}`); + // Era-exact validation via the function-only WireCodec contract: + // resolved from the instance era at dispatch time (the era gate + // guarantees tools/call exists on the serving era, so the + // `not-in-era` arm is an internal error). 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 codec = codecForVersion(this._negotiatedProtocolVersion); + const validatedRequest = codec.validateRequest('tools/call', request); + if (!validatedRequest.ok) { + throw new ProtocolError( + validatedRequest.reason === 'not-in-era' ? ProtocolErrorCode.InternalError : ProtocolErrorCode.InvalidParams, + validatedRequest.reason === 'not-in-era' + ? 'No wire schema for tools/call in the resolved era' + : `Invalid tools/call request: ${validatedRequest.message}` + ); } - const result = await handler(request, ctx); + const result = await this._invokeInputRequiredCapableHandler('tools/call', handler, request, ctx); + if (isInputRequiredResult(result)) { + // Already checked by the seam; the CallToolResult schema does + // not apply to it (no widening — InputRequiredResult travels + // alongside). + return result; + } - const validationResult = parseSchema(CallToolResultSchema, result); - if (!validationResult.success) { - const errorMessage = - validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid tools/call result: ${errorMessage}`); + const validationResult = codec.validateResult('tools/call', result); + if (!validationResult.ok) { + throw new ProtocolError( + validationResult.reason === 'not-in-era' ? ProtocolErrorCode.InternalError : ProtocolErrorCode.InvalidParams, + validationResult.reason === 'not-in-era' + ? 'No wire schema for tools/call in the resolved era' + : `Invalid tools/call result: ${validationResult.message}` + ); } - return validationResult.data; + return validationResult.value; }; } + /** + * Whether this instance is bound to a 2026-07-28-or-later protocol + * revision. Era is instance state — a serving entry (`createMcpHandler`, + * `serveStdio`) marks the instance modern at construction; a 2025-era + * `initialize` handshake binds it legacy. The multi-round-trip seam reads + * this directly: there is no per-request era consult. + */ + private _servedModernEra(): boolean { + return this._negotiatedProtocolVersion !== undefined && isModernProtocolVersion(this._negotiatedProtocolVersion); + } + + /** + * Invokes a handler for one of the multi-round-trip methods and applies + * the input-required seam: + * + * - a `UrlElicitationRequiredError` (or any 2025-style server→client + * request idiom) escaping the handler on a request served on the + * 2026-07-28 era fails LOUDLY with a clear steer to + * `inputRequired.elicitUrl(...)` — the `-32042` error never reaches the + * 2026-07-28 wire and the throw is not silently converted. Requests + * served on the 2025 era keep today's `-32042` behavior byte-exact (the + * error is rethrown unchanged). + * - an input-required RETURN is only legal toward the 2026-07-28 era; it + * must satisfy the at-least-one rule (`inputRequests` or + * `requestState`), and every embedded request must be covered by the + * capabilities the client declared on this request's envelope + * (violations answer with the typed `-32021` error). + */ + private async _invokeInputRequiredCapableHandler( + method: string, + handler: (request: JSONRPCRequest, ctx: ServerContext) => Promise, + request: JSONRPCRequest, + ctx: ServerContext + ): Promise { + const servedModern = this._servedModernEra(); + + // The configured requestState.verify hook runs above the handler (and + // therefore above the McpServer tools/call funnel), so a rejection + // reaches the wire as a real JSON-RPC error rather than an `isError` + // tool result. The wire message is FROZEN — the thrown reason is + // surfaced via `onerror` only. A non-string `requestState` value (the + // wire field is `string | undefined`) is treated as invalid regardless + // of whether a hook is configured, so a malformed value cannot bypass + // verification. + const rawRequestState = ctx.mcpReq.requestState as unknown; + if (rawRequestState !== undefined && typeof rawRequestState !== 'string') { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Invalid or expired requestState', { + reason: 'invalid_request_state' + }); + } + if (this._requestStateVerify !== undefined && typeof rawRequestState === 'string') { + try { + await this._requestStateVerify(rawRequestState, ctx); + } catch (error) { + this.onerror?.( + new Error(`requestState verification rejected ${method}: ${error instanceof Error ? error.message : String(error)}`) + ); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Invalid or expired requestState', { + reason: 'invalid_request_state' + }); + } + } + + let result: Result; + try { + result = await handler(request, ctx); + } catch (error) { + if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { + if (!servedModern) { + // 2025-era behavior is frozen: the error reaches the wire + // exactly as it does today. + throw error; + } + // 2026-era requests do not carry the -32042 surface. A + // 2025-style throw fails loudly with a clear steer rather than + // being converted: the handler should return + // inputRequired.elicitUrl(...) instead. + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `URL elicitation cannot be signalled by throwing UrlElicitationRequiredError on protocol revision ` + + `${this._negotiatedProtocolVersion}: return inputRequired({ inputRequests: { …: inputRequired.elicitUrl(...) } }) ` + + `from the handler instead. The urlElicitationRequired error (-32042) of earlier revisions is not ` + + `available on this revision.` + ); + } + throw error; + } + + if (!isInputRequiredResult(result)) { + return result; + } + + if (!servedModern) { + // The 2025-era wire has no input_required vocabulary: fail loudly + // rather than putting a mis-typed result on the wire. A handler + // that serves both eras branches on the served era and uses the + // push-style APIs toward 2025-era requests. + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned an input-required result, but this request is served on protocol revision ` + + `${this._negotiatedProtocolVersion ?? LATEST_PROTOCOL_VERSION}, which has no input_required vocabulary` + ); + } + + // F7 at-least-one re-check (hand-built results are legal; the rule is + // re-checked at the seam). + const inputRequests = result.inputRequests as Record | null | undefined; + const hasInputRequests = inputRequests != null && Object.keys(inputRequests).length > 0; + const hasRequestState = typeof result.requestState === 'string'; + if (!hasInputRequests && !hasRequestState) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned an input-required result with neither inputRequests nor requestState ` + + `(every InputRequiredResult must include at least one of the two)` + ); + } + + // Per-embedded-request capability check against the capabilities the + // client declared on THIS request's envelope (-32021 on violation). + if (hasInputRequests) { + const declared = ctx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined; + for (const [key, entry] of Object.entries(inputRequests)) { + if (entry === null || typeof entry !== 'object' || typeof (entry as { method?: unknown }).method !== 'string') { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned an invalid input request '${key}': each inputRequests entry must be an ` + + `embedded elicitation/create, sampling/createMessage, or roots/list request` + ); + } + const embedded = entry as { method: string; params?: Record }; + const required = requiredClientCapabilitiesForInputRequest(embedded); + if (required === undefined) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Handler for ${method} returned an input request '${key}' of kind '${embedded.method}', which is not an ` + + `embedded request the 2026-07-28 revision defines` + ); + } + const missing = missingClientCapabilities(required, declared); + if (missing !== undefined) { + throw new MissingRequiredClientCapabilityError( + { requiredCapabilities: missing }, + `Cannot request input '${key}' (${embedded.method}): the request's client capabilities do not declare ` + + `the required capability` + ); + } + } + } + + return result; + } + + /** + * Guard for the push-style server→client request APIs ({@linkcode createMessage}, + * {@linkcode elicitInput}, {@linkcode listRoots}, {@linkcode ping}) on a + * modern-era instance: the 2026-07-28 revision has no server→client request + * channel, so the call fails before any wire traffic with a typed error + * whose message steers to `inputRequired(...)`. The base era gate would + * also reject it; this guard runs first to carry the steer. + */ + private _assertPushApiInServedEra(method: string): void { + if (this._servedModernEra()) { + throw new SdkError( + SdkErrorCode.MethodNotSupportedByProtocolVersion, + `Server-to-client requests are not available on protocol revision ${this._negotiatedProtocolVersion}: ` + + `'${method}' cannot be sent while serving a request on that revision. ` + + `Return inputRequired({ ... }) from the handler instead — the client fulfils the embedded ` + + `requests and retries the original request (multi round-trip requests).`, + { method, era: '2026-07-28' } + ); + } + } + protected assertCapabilityForMethod(method: RequestMethod | string): void { switch (method) { case 'sampling/createMessage': { @@ -375,10 +807,17 @@ 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); + : (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). this._negotiatedProtocolVersion = protocolVersion; this.transport?.setProtocolVersion?.(protocolVersion); @@ -390,8 +829,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 +859,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,11 +874,39 @@ 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. */ getNegotiatedProtocolVersion(): string | undefined { return this._negotiatedProtocolVersion; } + /** + * Project a `tools/call` result through this instance's negotiated wire + * codec — the era-agnostic SEP-2106 §4.3 TextContent auto-append, plus on + * the 2025 era the `{result:…}` wrap when `structuredContent` is a + * non-object value or the advertised `outputSchema` had a non-object root. + * Identity for object-shaped `structuredContent` on the 2026 era. + * + * `McpServer`'s built-in `tools/call` handler routes through this method. + * Low-level `setRequestHandler('tools/call', …)` authors call it + * themselves so the projection lives in one place (the codec) and the + * server-side handler stays era-blind. + * + * This is the only codec function exposed on `Server` — the full + * `WireCodec` is intentionally not part of the public surface. + */ + public projectCallToolResult( + result: CallToolResult, + advertisedOutputSchema: Readonly> | undefined + ): CallToolResult { + return this._wireCodec().projectCallToolResult(result, advertisedOutputSchema); + } + /** * Returns the current server capabilities. */ @@ -420,8 +914,15 @@ export class Server extends Protocol { return this._capabilities; } - async ping() { - return this._requestWithSchema({ method: 'ping' }, EmptyResultSchema); + /** + * Sends a `ping` request to the connected client. + * + * @deprecated The 2026-07-28 protocol removed ping; it throws on a 2026-07-28-era instance. + * If your factory serves both eras, this only works on the legacy path. + */ + async ping(): Promise { + this._assertPushApiInServedEra('ping'); + return this.request({ method: 'ping' }); } /** @@ -429,8 +930,10 @@ export class Server extends Protocol { * Returns single content block for backwards compatibility. * * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). - * Remains functional during the deprecation window (at least twelve months). - * Migrate to calling LLM provider APIs directly. + * Throws on a 2026-07-28-era request — use {@link index.inputRequired | inputRequired} (multi-round-trip) instead, + * or migrate to calling LLM provider APIs directly. The 2025 push-style server-to-client + * request model is replaced by input_required results in the 2026-07-28 protocol. If your + * factory serves both eras, this only works on the legacy path. */ async createMessage(params: CreateMessageRequestParamsBase, options?: RequestOptions): Promise; @@ -439,8 +942,10 @@ export class Server extends Protocol { * Returns content that may be a single block or array (for parallel tool calls). * * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). - * Remains functional during the deprecation window (at least twelve months). - * Migrate to calling LLM provider APIs directly. + * Throws on a 2026-07-28-era request — use {@link index.inputRequired | inputRequired} (multi-round-trip) instead, + * or migrate to calling LLM provider APIs directly. The 2025 push-style server-to-client + * request model is replaced by input_required results in the 2026-07-28 protocol. If your + * factory serves both eras, this only works on the legacy path. */ async createMessage(params: CreateMessageRequestParamsWithTools, options?: RequestOptions): Promise; @@ -449,8 +954,10 @@ export class Server extends Protocol { * When tools may or may not be present, returns the union type. * * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). - * Remains functional during the deprecation window (at least twelve months). - * Migrate to calling LLM provider APIs directly. + * Throws on a 2026-07-28-era request — use {@link index.inputRequired | inputRequired} (multi-round-trip) instead, + * or migrate to calling LLM provider APIs directly. The 2025 push-style server-to-client + * request model is replaced by input_required results in the 2026-07-28 protocol. If your + * factory serves both eras, this only works on the legacy path. */ async createMessage( params: CreateMessageRequest['params'], @@ -462,6 +969,7 @@ export class Server extends Protocol { params: CreateMessageRequest['params'], options?: RequestOptions ): Promise { + this._assertPushApiInServedEra('sampling/createMessage'); // Capability check - only required when tools/toolChoice are provided if ((params.tools || params.toolChoice) && !this._clientCapabilities?.sampling?.tools) { throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support sampling tools capability.'); @@ -511,11 +1019,25 @@ export class Server extends Protocol { } } - // Use different schemas based on whether tools are provided - if (params.tools) { - return this._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultWithToolsSchema, options); + // The result schema depends on the REQUEST params (tools vs no tools), + // which a method-keyed registry entry cannot express — the era codec's + // `samplingResultVariant` owns the with-tools/plain pair. The funnel's + // registry-result path runs first (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), then the variant validator narrows the wide result. + const hasTools = Boolean(params.tools || params.toolChoice); + const wide = await this.request({ method: 'sampling/createMessage', params }, options); + const outcome = this._wireCodec().samplingResultVariant(hasTools, wide); + if (!outcome.ok) { + // `not-in-era` is unreachable on the path that gets here (the era + // gate above filters out 2026 instances); `invalid` is a peer bug. + throw new SdkError( + SdkErrorCode.InvalidResult, + `Invalid sampling/createMessage result: ${outcome.reason === 'invalid' ? outcome.message : outcome.reason}` + ); } - return this._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultSchema, options); + return outcome.value; } /** @@ -524,8 +1046,14 @@ export class Server extends Protocol { * @param params The parameters for the elicitation request. * @param options Optional request options. * @returns The result of the elicitation request. + * + * @deprecated Throws on a 2026-07-28-era request — use {@link index.inputRequired | inputRequired} (multi-round-trip) + * instead. The 2025 push-style server-to-client request model is replaced by input_required + * results in the 2026-07-28 protocol. If your factory serves both eras, this only works on the + * legacy path. */ async elicitInput(params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise { + this._assertPushApiInServedEra('elicitation/create'); const mode = (params.mode ?? 'form') as 'form' | 'url'; switch (mode) { @@ -535,7 +1063,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 +1075,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 { @@ -581,6 +1107,11 @@ export class Server extends Protocol { * Creates a reusable callback that, when invoked, will send a `notifications/elicitation/complete` * notification for the specified elicitation ID. * + * The notification (and the `elicitationId` it references) exists only on protocol revision + * 2025-11-25 — the 2026-07-28 draft removed both. On a connection negotiated at 2026-07-28 the + * returned callback rejects with a typed local error before anything reaches the transport + * (the method is not part of that revision's wire registry). + * * @param elicitationId The ID of the elicitation to mark as complete. * @param options Optional notification options. Useful when the completion notification should be related to a prior request. * @returns A function that emits the completion notification when awaited. @@ -609,11 +1140,14 @@ export class Server extends Protocol { * Requests the list of roots from the client. * * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). - * Remains functional during the deprecation window (at least twelve months). - * Migrate to passing paths via tool parameters, resource URIs, or configuration. + * Throws on a 2026-07-28-era request — use {@link index.inputRequired | inputRequired} (multi-round-trip) instead, + * or migrate to passing paths via tool parameters, resource URIs, or configuration. The 2025 + * push-style server-to-client request model is replaced by input_required results in the + * 2026-07-28 protocol. If your factory serves both eras, this only works on the legacy path. */ - async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions) { - return this._requestWithSchema({ method: 'roots/list', params }, ListRootsResultSchema, options); + async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions): Promise { + this._assertPushApiInServedEra('roots/list'); + return this.request({ method: 'roots/list', params }, options); } /** @@ -654,3 +1188,16 @@ export class Server extends Protocol { return this.notification({ method: 'notifications/prompts/list_changed' }); } } + +/** + * The capability set a server advertises on `server/discover`. Pure — never + * mutates the input; the legacy `initialize` advertisement is untouched. + * + * The serving entries serve `subscriptions/listen` themselves, so the + * `listChanged` and `resources.subscribe` capability bits are advertised + * as-is: a modern-era client uses them to decide which notification types to + * request on its listen filter. + */ +export function discoverAdvertisedCapabilities(capabilities: ServerCapabilities): ServerCapabilities { + return { ...capabilities }; +} diff --git a/packages/server/src/server/serverEventBus.ts b/packages/server/src/server/serverEventBus.ts new file mode 100644 index 0000000000..20575a7455 --- /dev/null +++ b/packages/server/src/server/serverEventBus.ts @@ -0,0 +1,194 @@ +import type { ServerCapabilities, SubscriptionFilter } from '@modelcontextprotocol/core'; + +/** + * A change event a server publishes for delivery on open `subscriptions/listen` + * streams. Each variant maps onto exactly one notification method: + * + * - `tools_list_changed` → `notifications/tools/list_changed` + * - `prompts_list_changed` → `notifications/prompts/list_changed` + * - `resources_list_changed` → `notifications/resources/list_changed` + * - `resource_updated` → `notifications/resources/updated` (carries the URI) + * + * The bus carries the EVENT, not the wire shape — the entry's listen router + * owns subscription-id stamping and per-stream filtering. + */ +export type ServerEvent = + | { kind: 'tools_list_changed' } + | { kind: 'prompts_list_changed' } + | { kind: 'resources_list_changed' } + | { kind: 'resource_updated'; uri: string }; + +/** + * The server-side change-event seam for `subscriptions/listen`. + * + * The serving entry (`createMcpHandler`) owns the per-stream listen router: + * each open `subscriptions/listen` stream registers a listener via + * `subscribe()`, and consumer code (typically via `handler.notify.*` sugar) + * publishes change events via `publish()`. In-process servers can use the + * default {@linkcode InMemoryServerEventBus}; multi-process deployments + * implement this interface over their own pub/sub. + * + * The SDK owns wire semantics (ack-first, filtering, subscription-id + * stamping, teardown); a `ServerEventBus` only sources the events. It MUST + * NOT echo back to the listener that published an event when called from + * inside that listener (no surprise here — the default delivers + * synchronously and listeners never publish). + */ +export interface ServerEventBus { + /** + * Publish a change event to every registered listener. + */ + publish(event: ServerEvent): void; + /** + * Register a listener; returns an idempotent unsubscribe function. + */ + subscribe(listener: (event: ServerEvent) => void): () => void; +} + +/** + * A `ServerEventBus` backed by an in-process listener set. + * + * `publish()` delivers synchronously to the live listener set (a listener + * unsubscribing itself mid-dispatch is safe; the entry's listen-router + * listeners never unsubscribe peers). A throwing listener does not stop + * delivery to the others. + */ +export class InMemoryServerEventBus implements ServerEventBus { + private readonly _listeners = new Set<(event: ServerEvent) => void>(); + + /** + * @param onerror - Optional callback for errors thrown by listeners + * during dispatch. + */ + constructor(private readonly onerror?: (error: Error) => void) {} + + publish(event: ServerEvent): void { + for (const listener of this._listeners) { + try { + listener(event); + } catch (error) { + this.onerror?.(error instanceof Error ? error : new Error(String(error))); + } + } + } + + subscribe(listener: (event: ServerEvent) => void): () => void { + this._listeners.add(listener); + let live = true; + return () => { + if (!live) return; + live = false; + this._listeners.delete(listener); + }; + } + + /** The number of currently registered listeners (test/introspection only — the routers track capacity via their own open-subscription set). */ + get listenerCount(): number { + return this._listeners.size; + } +} + +/** + * Typed publish-side facade over `bus.publish` returned by `createMcpHandler`: + * each method publishes the corresponding {@linkcode ServerEvent}. Prefer this + * over calling `bus.publish` directly — the names match the wire methods. + */ +export interface ServerNotifier { + /** Publish `notifications/tools/list_changed` to every open subscription that opted in. */ + toolsChanged(): void; + /** Publish `notifications/prompts/list_changed` to every open subscription that opted in. */ + promptsChanged(): void; + /** Publish `notifications/resources/list_changed` to every open subscription that opted in. */ + resourcesChanged(): void; + /** Publish `notifications/resources/updated` for `uri` to every open subscription that opted in to that URI. */ + resourceUpdated(uri: string): void; +} + +/** Build a {@linkcode ServerNotifier} over a bus. */ +export function createServerNotifier(bus: ServerEventBus): ServerNotifier { + return { + toolsChanged: () => bus.publish({ kind: 'tools_list_changed' }), + promptsChanged: () => bus.publish({ kind: 'prompts_list_changed' }), + resourcesChanged: () => bus.publish({ kind: 'resources_list_changed' }), + resourceUpdated: (uri: string) => bus.publish({ kind: 'resource_updated', uri }) + }; +} + +/** + * Whether a `subscriptions/listen` filter accepts a given change event. + * + * Pure: no I/O, no mutation. The filter governs ONLY the four + * subscription-gated change types — non-gated notifications never reach the + * bus and are not modeled here. + * + * `resource_updated` matches only when `resourceSubscriptions` is present and + * contains the event's URI exactly (per the spec: "for these resource URIs"). + */ +export function listenFilterAccepts(filter: SubscriptionFilter, event: ServerEvent): boolean { + switch (event.kind) { + case 'tools_list_changed': { + return filter.toolsListChanged === true; + } + case 'prompts_list_changed': { + return filter.promptsListChanged === true; + } + case 'resources_list_changed': { + return filter.resourcesListChanged === true; + } + case 'resource_updated': { + return filter.resourceSubscriptions !== undefined && filter.resourceSubscriptions.includes(event.uri); + } + } +} + +/** + * The honored subset of a requested filter: keeps only the fields the client + * explicitly opted in to (drops `false` and absent fields), narrowed against + * the server's declared capabilities when supplied. The serving entry sends + * this back in `notifications/subscriptions/acknowledged` so the ack reflects + * what the server can actually deliver. + * + * - `toolsListChanged` is honored only when `capabilities.tools.listChanged` + * is advertised; likewise `promptsListChanged` / `resourcesListChanged`. + * - `resourceSubscriptions` is honored only when + * `capabilities.resources.subscribe` is advertised. + * + * `capabilities` is optional on this pure helper for test convenience only — + * both wired routers REQUIRE capabilities at the call site (the HTTP router's + * `serve()` takes a required parameter; `StdioListenRouter.serve()` throws + * before `setServerCapabilities()` was called), so the fail-open + * `undefined → honor everything` branch is never reachable on a wired entry. + */ +export function honoredSubset(requested: SubscriptionFilter, capabilities?: ServerCapabilities): SubscriptionFilter { + const honored: SubscriptionFilter = {}; + const allow = (bit: unknown): boolean => capabilities === undefined || bit === true; + if (requested.toolsListChanged === true && allow(capabilities?.tools?.listChanged)) honored.toolsListChanged = true; + if (requested.promptsListChanged === true && allow(capabilities?.prompts?.listChanged)) honored.promptsListChanged = true; + if (requested.resourcesListChanged === true && allow(capabilities?.resources?.listChanged)) honored.resourcesListChanged = true; + if ( + requested.resourceSubscriptions !== undefined && + requested.resourceSubscriptions.length > 0 && + allow(capabilities?.resources?.subscribe) + ) { + honored.resourceSubscriptions = [...requested.resourceSubscriptions]; + } + return honored; +} + +/** Map a {@linkcode ServerEvent} onto its wire notification `{method, params}`. */ +export function serverEventToNotification(event: ServerEvent): { method: string; params?: { uri: string } } { + switch (event.kind) { + case 'tools_list_changed': { + return { method: 'notifications/tools/list_changed' }; + } + case 'prompts_list_changed': { + return { method: 'notifications/prompts/list_changed' }; + } + case 'resources_list_changed': { + return { method: 'notifications/resources/list_changed' }; + } + case 'resource_updated': { + return { method: 'notifications/resources/updated', params: { uri: event.uri } }; + } + } +} diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index fb6ffc2b02..c63ab6e7e2 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -63,6 +63,12 @@ interface StreamMapping { encoder?: InstanceType; /** Promise resolver for JSON response mode */ resolveJson?: (response: Response) => void; + /** + * Event ids already written to this stream by `replayEventsAfter` — lets + * `send()` skip a duplicate write when the resumed stream registered + * during the `storeEvent()` await and replay already delivered the event. + */ + replayedEventIds?: Set; /** Cleanup function to close stream and remove mapping */ cleanup: () => void; } @@ -462,8 +468,12 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { streamController = controller; }, cancel: () => { - // Stream was cancelled by client - this._streamMapping.delete(this._standaloneSseStreamId); + // Stream was cancelled by client. Only drop the mapping when + // it still points at THIS controller — a stale cancel must not + // delete a successor stream registered by a later GET/resume. + if (this._streamMapping.get(this._standaloneSseStreamId)?.controller === streamController) { + this._streamMapping.delete(this._standaloneSseStreamId); + } } }); @@ -536,20 +546,33 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { // Create a ReadableStream with controller for SSE const encoder = new TextEncoder(); let streamController: ReadableStreamDefaultController; + // Captured by the cancel closure below before it's assigned (after + // replayEventsAfter resolves) — must be `let`. + // eslint-disable-next-line prefer-const + let replayedStreamId: string | undefined; const readable = new ReadableStream({ start: controller => { streamController = controller; }, cancel: () => { - // Stream was cancelled by client - // Cleanup will be handled by the mapping + // Stream was cancelled by client — drop the mapping so a + // subsequent reconnect with the same Last-Event-ID is not + // refused with 409 by the conflict check above. Only delete + // when the mapped entry is still THIS closure's controller: + // a stale cancel from an earlier resume must not delete a + // successor resumed stream a re-poll has since registered. + if (replayedStreamId !== undefined && this._streamMapping.get(replayedStreamId)?.controller === streamController) { + this._streamMapping.delete(replayedStreamId); + } } }); // Replay events - returns the streamId for backwards compatibility - const replayedStreamId = await this._eventStore.replayEventsAfter(lastEventId, { + const replayedEventIds = new Set(); + replayedStreamId = await this._eventStore.replayEventsAfter(lastEventId, { send: async (eventId: string, message: JSONRPCMessage) => { + replayedEventIds.add(eventId); const success = this.writeSSEEvent(streamController!, encoder, message, eventId); if (!success) { try { @@ -564,8 +587,9 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { this._streamMapping.set(replayedStreamId, { controller: streamController!, encoder, + replayedEventIds, cleanup: () => { - this._streamMapping.delete(replayedStreamId); + this._streamMapping.delete(replayedStreamId!); try { streamController!.close(); } catch { @@ -574,6 +598,25 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } }); + // If this is a per-request stream and no in-flight request still + // targets this streamId, the request was already retired by the + // clean-return path while disconnected and the replay above just + // delivered the final response. Per the spec the server SHOULD + // close the SSE stream after the JSON-RPC response — close and + // unregister so a later reconnect isn't refused with 409. The + // standalone GET stream is never request-scoped and stays open. + if (replayedStreamId !== this._standaloneSseStreamId) { + const hasInFlightRequest = [...this._requestToStreamMapping.values()].includes(replayedStreamId); + if (!hasInFlightRequest) { + this._streamMapping.delete(replayedStreamId); + try { + streamController!.close(); + } catch { + // Controller might already be closed + } + } + } + return new Response(readable, { headers }); } catch (error) { this.onerror?.(error as Error); @@ -680,6 +723,10 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { // Check if this is an initialization request // https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle/ + // The schema-validated guard (types/guards.ts → types/schemas.ts — + // NOT a wire/rev* import) gates a transport state mutation: a + // malformed `initialize` must NOT set `_initialized = true` before + // the protocol layer rejects it. const isInitializationRequest = messages.some(element => isInitializeRequest(element)); if (isInitializationRequest) { // If it's a server with session management and the session ID is already set we should reject the request @@ -770,8 +817,14 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { streamController = controller; }, cancel: () => { - // Stream was cancelled by client - this._streamMapping.delete(streamId); + // Stream was cancelled by client. Only drop the mapping + // when it still points at THIS controller — a stale cancel + // (firing after a Last-Event-ID reconnect registered a + // resumed stream under the same streamId) must not delete + // the successor. + if (this._streamMapping.get(streamId)?.controller === streamController) { + this._streamMapping.delete(streamId); + } } }); @@ -987,8 +1040,14 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { return; } - // Send the message to the standalone SSE stream - if (standaloneSse.controller && standaloneSse.encoder) { + // Send the message to the standalone SSE stream — unless the + // resumed stream's replay already delivered this exact eventId + // (identity dedup; mirrors the per-request path below). + if ( + standaloneSse.controller && + standaloneSse.encoder && + (eventId === undefined || !standaloneSse.replayedEventIds?.has(eventId)) + ) { this.writeSSEEvent(standaloneSse.controller, standaloneSse.encoder, message, eventId); } return; @@ -1000,17 +1059,33 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { throw new Error(`No connection established for request ID: ${String(requestId)}`); } - const stream = this._streamMapping.get(streamId); - - if (!this._enableJsonResponse && stream?.controller && stream?.encoder) { - // For SSE responses, generate event ID if event store is provided + let stream = this._streamMapping.get(streamId); + + if (!this._enableJsonResponse) { + // Store FIRST so request-related events emitted while the per-request + // stream is disconnected (e.g. after `closeSSE()` or a transient + // client drop) are replayed on Last-Event-ID reconnect — same + // store-first semantics as the standalone path above. Storage is + // keyed on request-in-flight (`_requestToStreamMapping` resolved + // `streamId` above), not on whether a live SSE writer currently + // exists: `_streamMapping` tracks the delivery target only. Per + // 2025-11-25 transports.mdx, disconnection SHOULD NOT be + // interpreted as the client cancelling its request. let eventId: string | undefined; - if (this._eventStore) { eventId = await this._eventStore.storeEvent(streamId, message); + // Re-read after the await: a Last-Event-ID reconnect during + // storeEvent() may have registered a resumed stream under this + // streamId (mirrors the standalone path's post-await read). + stream = this._streamMapping.get(streamId); + } + // Write the event to the response stream — unless the resumed + // stream's replay already delivered this exact eventId (the store + // committed before replay scanned, so replay wrote it; identity + // dedup only, no ordering assumption). + if (stream?.controller && stream?.encoder && (eventId === undefined || !stream.replayedEventIds?.has(eventId))) { + this.writeSSEEvent(stream.controller, stream.encoder, message, eventId); } - // Write the event to the response stream - this.writeSSEEvent(stream.controller, stream.encoder, message, eventId); } if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { @@ -1022,7 +1097,37 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { if (allResponsesReady) { if (!stream) { - throw new Error(`No connection established for request ID: ${String(requestId)}`); + if (this._enableJsonResponse) { + // JSON-mode requires a resolveJson sink; with no stream entry the + // response is undeliverable. + throw new Error(`No connection established for request ID: ${String(requestId)}`); + } + if (!this._eventStore) { + // SSE-mode with no live writer and no event store: the + // response is undeliverable AND not stored. Surface via + // onerror so the drop is observable (matching pre-PR + // behaviour), then run the bookkeeping cleanup so the + // request id is retired. + this.onerror?.( + new Error( + `Response for request ID ${String(requestId)} is undeliverable: per-request stream is disconnected and no eventStore is configured` + ) + ); + for (const id of relatedIds) { + this._requestResponseMap.delete(id); + this._requestToStreamMapping.delete(id); + } + return; + } + // SSE-mode with no live writer and an event store configured: + // the response was stored above for replay on Last-Event-ID + // reconnect. Return cleanly after running the bookkeeping + // cleanup so the request id is retired. + for (const id of relatedIds) { + this._requestResponseMap.delete(id); + this._requestToStreamMapping.delete(id); + } + return; } if (this._enableJsonResponse && stream.resolveJson) { // All responses ready, send as JSON @@ -1040,6 +1145,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } else { stream.resolveJson(Response.json(responses, { status: 200, headers })); } + stream.cleanup(); } else { // End the SSE stream stream.cleanup(); diff --git a/packages/server/src/stdio.ts b/packages/server/src/stdio.ts index 7865c9cedc..deaa8468db 100644 --- a/packages/server/src/stdio.ts +++ b/packages/server/src/stdio.ts @@ -1,8 +1,11 @@ -// Subpath entry for the stdio server transport. +// Subpath entry for stdio serving. // -// Exported separately from the root entry to keep `StdioServerTransport` out of the default bundle +// Exported separately from the root entry to keep the process-stdio surface (`StdioServerTransport` +// and the `serveStdio` entry point, which constructs one by default) out of the default bundle // surface — server stdio has only type-level Node imports, but matching the client's `./stdio` // subpath gives consumers a consistent shape across packages. Import from // `@modelcontextprotocol/server/stdio` only in process-stdio runtimes (Node.js, Bun, Deno). +export type { ServeStdioOptions, StdioServerHandle } from './server/serveStdio.js'; +export { serveStdio } from './server/serveStdio.js'; export { StdioServerTransport } from './server/stdio.js'; 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..fca9ac41ab --- /dev/null +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -0,0 +1,816 @@ +/** + * createMcpHandler: the dual-era HTTP entry. + * + * Covers the two legacy postures ('stateless' — the default — and 'reject' → + * modern-only strict), the isLegacyRequest predicate and the user-land routing + * pattern that replaces the removed handler-valued legacy option, 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 { 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 } from '../../src/server/createMcpHandler.js'; +import { createMcpHandler, isLegacyRequest } 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 } + }; +} + +/** + * The SEP-2243 standard headers a conformant client derives from the body it + * sends. Only emitted for a body carrying a modern envelope claim, so legacy + * test cells stay byte-untouched; spread before any explicit `headers` so a + * caller that needs to test a stripped or disagreeing header can override. + */ +function bodyDerivedStandardHeaders(body: unknown): Record { + if (body === null || typeof body !== 'object' || Array.isArray(body)) return {}; + const b = body as { method?: unknown; params?: { name?: unknown; uri?: unknown; _meta?: Record } }; + if (typeof b.params?._meta?.[PROTOCOL_VERSION_META_KEY] !== 'string') return {}; + const out: Record = {}; + if (typeof b.method === 'string') out['mcp-method'] = b.method; + const name = b.method === 'resources/read' ? b.params.uri : b.params.name; + if (typeof name === 'string') out['mcp-name'] = name; + return out; +} + +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', + ...bodyDerivedStandardHeaders(body), + ...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_022); + 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 -32020 (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_020); + // 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: 'reject')", () => { + 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, { legacy: 'reject', 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_022); + 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, { legacy: 'reject' }); + + 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_022); + 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, { legacy: 'reject' }); + + 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, { legacy: 'reject' }); + + 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, { legacy: 'reject' }); + + 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, { legacy: 'reject' }); + + 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, { legacy: 'reject' }); + + 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, { legacy: 'reject' }); + 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 — stateless legacy fallback (the default)', () => { + it('serves a 2025-era client by default through the frozen stateless idiom with a fresh instance per request', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + 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("serves the same legacy traffic when 'stateless' is passed explicitly (the explicit value of the default)", async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + const initialize = await handler.fetch( + postRequest({ + jsonrpc: '2.0', + id: 'init-2', + 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"'); + expect(state.contexts[0]?.era).toBe('legacy'); + }); + + it('answers GET and DELETE like the canonical stateless example (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.'); + } + }); + + it('routes legacy notification POSTs to the legacy leg (202 acknowledged by the stateless transport)', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + 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); + + 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); + + 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); + + 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-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' }); + }, + { 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 the fallback active)', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + // 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); + + 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 — user-land routing with isLegacyRequest (replaces the handler-valued legacy option)', () => { + it('routes legacy traffic to an existing handler with the original bytes untouched, alongside a strict modern entry', async () => { + const { factory, state } = testFactory(); + const original = { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }; + let receivedBody: string | undefined; + const existingLegacyHandler = vi.fn(async (request: Request) => { + receivedBody = await request.text(); + return new Response('legacy-served', { status: 299 }); + }); + const modern = createMcpHandler(factory, { legacy: 'reject' }); + // The documented routing pattern: the predicate decides, the strict + // entry serves everything that is not legacy. + const route = async (request: Request): Promise => { + if (await isLegacyRequest(request)) { + return existingLegacyHandler(request); + } + return modern.fetch(request); + }; + + // A claim-less 2025 request reaches the existing handler with its body + // still readable — the predicate classifies a clone, never the original. + const response = await route(postRequest(original)); + expect(response.status).toBe(299); + expect(await response.text()).toBe('legacy-served'); + expect(receivedBody).toBe(JSON.stringify(original)); + + // GET/DELETE are method-routed to the existing handler too (sessionful wirings own them). + const get = await route(new Request('http://localhost/mcp', { method: 'GET' })); + expect(get.status).toBe(299); + + // Modern envelope traffic never reaches the legacy handler. + const modernResponse = await route(postRequest(modernToolsCall('echo', { text: 'hi' }))); + expect(modernResponse.status).toBe(200); + expect(existingLegacyHandler).toHaveBeenCalledTimes(2); + expect(state.contexts.filter(ctx => ctx.era === 'modern')).toHaveLength(1); + + // A malformed modern claim is NOT legacy: it goes to the modern entry, + // which answers the validation-ladder error (-32602), never the legacy handler. + const malformed = await route( + postRequest(modernToolsCall('echo', { text: 'x' }, { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION })) + ); + expect(malformed.status).toBe(400); + expect(((await malformed.json()) as JSONRPCErrorBody).error.code).toBe(-32_602); + expect(existingLegacyHandler).toHaveBeenCalledTimes(2); + }); + + it('isLegacyRequest agrees with the entry classification rung across the routing cells', async () => { + const legacyShaped: Array<{ name: string; request: () => Request }> = [ + { + name: 'claim-less request', + request: () => postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }) + }, + { + name: 'initialize handshake', + request: () => + postRequest({ + jsonrpc: '2.0', + id: 'init-1', + method: 'initialize', + params: { protocolVersion: '2025-11-25', clientInfo: { name: 'legacy', version: '1.0' }, capabilities: {} } + }) + }, + { name: 'claim-less notification', request: () => postRequest({ jsonrpc: '2.0', method: 'notifications/initialized' }) }, + { name: 'GET session operation', request: () => new Request('http://localhost/mcp', { method: 'GET' }) }, + { name: 'DELETE session operation', request: () => new Request('http://localhost/mcp', { method: 'DELETE' }) }, + { + name: 'all-legacy batch array', + request: () => postRequest([{ jsonrpc: '2.0', method: 'notifications/initialized' }]) + }, + { name: 'posted JSON-RPC response', request: () => postRequest({ jsonrpc: '2.0', id: 9, result: { ok: true } }) }, + { name: 'unparseable body', request: () => postRequest('{not json') }, + { + name: 'claim-less server/discover (no envelope, classified like any other claim-less request)', + request: () => postRequest({ jsonrpc: '2.0', id: 4, method: 'server/discover', params: {} }) + } + ]; + const modernShaped: Array<{ name: string; request: () => Request }> = [ + { name: 'valid modern envelope', request: () => postRequest(modernToolsCall('echo', { text: 'x' })) }, + { + name: 'enveloped server/discover probe', + request: () => postRequest({ jsonrpc: '2.0', id: 5, method: 'server/discover', params: { _meta: ENVELOPE } }) + }, + { + name: 'envelope claiming an unsupported revision (modern path answers -32022)', + request: () => + postRequest(modernToolsCall('echo', { text: 'x' }, { ...ENVELOPE, [PROTOCOL_VERSION_META_KEY]: '2030-01-01' })) + }, + { + name: 'malformed envelope behind a present claim (modern path answers -32602)', + request: () => postRequest(modernToolsCall('echo', { text: 'x' }, { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION })) + }, + { + name: 'modern header without a claim (modern path answers -32602)', + request: () => + postRequest( + { jsonrpc: '2.0', id: 11, method: 'tools/list', params: {} }, + { 'mcp-protocol-version': MODERN_REVISION, 'mcp-method': 'tools/list' } + ) + }, + { + name: 'header/body mismatch (modern path answers -32020)', + request: () => postRequest(modernToolsCall('echo', { text: 'x' }), { 'mcp-protocol-version': '2025-11-25' }) + } + ]; + + for (const { name, request } of legacyShaped) { + expect(await isLegacyRequest(request()), name).toBe(true); + } + for (const { name, request } of modernShaped) { + expect(await isLegacyRequest(request()), name).toBe(false); + } + }); + + it('leaves the request body readable and accepts a pre-parsed body without reading the stream', async () => { + const original = { jsonrpc: '2.0', id: 7, method: 'tools/list', params: {} }; + + // Body stays readable after the predicate ran (it classified a clone). + const request = postRequest(original); + expect(await isLegacyRequest(request)).toBe(true); + expect(request.bodyUsed).toBe(false); + expect(await request.text()).toBe(JSON.stringify(original)); + + // With a pre-parsed body the request stream is never touched at all. + const preParsed = postRequest(original); + expect(await isLegacyRequest(preParsed, original)).toBe(true); + expect(preParsed.bodyUsed).toBe(false); + expect(await isLegacyRequest(postRequest(modernToolsCall('echo', { text: 'x' })), modernToolsCall('echo', { text: 'x' }))).toBe( + false + ); + }); + + it("throws a TypeError at construction when a handler function is passed as the 'legacy' option", () => { + const { factory } = testFactory(); + const myExistingLegacyHandler = async (): Promise => new Response(null, { status: 200 }); + const construct = () => createMcpHandler(factory, { legacy: myExistingLegacyHandler as unknown as 'stateless' }); + expect(construct).toThrow(TypeError); + expect(construct).toThrow(/isLegacyRequest/); + }); +}); + +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'); + }); + + // The Node `(req, res, parsedBody?)` adaptation moved to + // `toNodeHandler(handler)` in `@modelcontextprotocol/node`; its conversion + // semantics (stream read, pre-parsed body, req.auth pass-through, HTTP/2 + // pseudo-headers, write backpressure) are pinned at unit level there. +}); + +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 fallback untouched by close() until the handler itself refuses requests', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + await handler.close(); + await expect(handler.fetch(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }))).rejects.toThrow(/closed/); + }); +}); + +// 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..38d3463919 --- /dev/null +++ b/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts @@ -0,0 +1,103 @@ +/** + * 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 `-32021` 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', + 'mcp-method': 'tools/call', + 'mcp-name': 'echo' + }, + 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 -32021 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_021); + 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/createMcpHandlerListen.test.ts b/packages/server/test/server/createMcpHandlerListen.test.ts new file mode 100644 index 0000000000..2dd3fa372c --- /dev/null +++ b/packages/server/test/server/createMcpHandlerListen.test.ts @@ -0,0 +1,238 @@ +/** + * createMcpHandler — entry-handled `subscriptions/listen` router. + * + * Covers ack-first (the acknowledged notification is the first frame), + * subscription-id stamping (the listen request's JSON-RPC id verbatim), + * per-stream filtering (un-requested types provably never delivered), + * notify sugar, capacity guard, capability-narrowed honored filter, and + * teardown. + */ +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + SUBSCRIPTION_ID_META_KEY +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { createMcpHandler } from '../../src/server/createMcpHandler.js'; +import { McpServer } from '../../src/server/mcp.js'; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: '2026-07-28', + [CLIENT_INFO_META_KEY]: { name: 'listen-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +function listenRequest(id: string | number, filter: Record): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id, + method: 'subscriptions/listen', + params: { _meta: ENVELOPE, notifications: filter } + }) + }); +} + +/** Read N SSE `event: message` payloads from a streaming response, then cancel. */ +async function readMessages(response: Response, n: number): Promise { + expect(response.headers.get('Content-Type')).toBe('text/event-stream'); + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + const messages: unknown[] = []; + while (messages.length < n) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + const dataLine = frame.split('\n').find(l => l.startsWith('data: ')); + if (dataLine) messages.push(JSON.parse(dataLine.slice(6))); + } + } + await reader.cancel(); + return messages; +} + +function trivialFactory(): () => McpServer { + // Declare every listChanged / subscribe bit so the tests below see the + // requested filter honored as-is (the entry now narrows the ack against + // the per-serve instance's declared capabilities). + return () => + new McpServer( + { name: 'listen-test-server', version: '1.0.0' }, + { + capabilities: { + tools: { listChanged: true }, + prompts: { listChanged: true }, + resources: { listChanged: true, subscribe: true } + } + } + ); +} + +describe('createMcpHandler — subscriptions/listen', () => { + it('serves listen at the entry, consulting the factory only for its declared capabilities', async () => { + let factoryCalls = 0; + let connectCalls = 0; + let closeCalls = 0; + const handler = createMcpHandler( + () => { + factoryCalls++; + const s = new McpServer({ name: 's', version: '1' }); + const { connect, close } = s; + s.connect = tx => { + connectCalls++; + return connect.call(s, tx); + }; + s.close = () => { + closeCalls++; + return close.call(s); + }; + return s; + }, + { keepAliveMs: 0 } + ); + const response = await handler.fetch(listenRequest(1, { toolsListChanged: true })); + expect(response.status).toBe(200); + const [ack] = await readMessages(response, 1); + // The factory is consulted exactly once (capabilities probe only); the + // instance is never connected and is closed immediately after the + // capabilities read so a factory-allocated resource cannot leak. + expect(factoryCalls).toBe(1); + expect(connectCalls).toBe(0); + expect(closeCalls).toBe(1); + expect((ack as { method: string }).method).toBe('notifications/subscriptions/acknowledged'); + await handler.close(); + }); + + it('ack is the first frame, stamped with the listen id verbatim, carrying the honored subset', async () => { + const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0 }); + const response = await handler.fetch(listenRequest('sub-42', { toolsListChanged: true, promptsListChanged: false })); + const [ack] = await readMessages(response, 1); + expect(ack).toEqual({ + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: 'sub-42' }, notifications: { toolsListChanged: true } } + }); + await handler.close(); + }); + + it('delivers only opted-in change types, each stamped with the subscription id', async () => { + const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0 }); + const response = await handler.fetch(listenRequest(7, { toolsListChanged: true, resourceSubscriptions: ['file:///a'] })); + // Publish before reading: a stream that did NOT opt in to prompts must + // never see the prompts notification (provably-never-delivered). + handler.notify.promptsChanged(); + handler.notify.toolsChanged(); + handler.notify.resourceUpdated('file:///b'); + handler.notify.resourceUpdated('file:///a'); + const messages = (await readMessages(response, 3)) as { method: string; params: Record }[]; + expect(messages.map(m => m.method)).toEqual([ + 'notifications/subscriptions/acknowledged', + 'notifications/tools/list_changed', + 'notifications/resources/updated' + ]); + expect(messages[2]!.params).toEqual({ _meta: { [SUBSCRIPTION_ID_META_KEY]: 7 }, uri: 'file:///a' }); + for (const m of messages) { + expect((m.params['_meta'] as Record)[SUBSCRIPTION_ID_META_KEY]).toBe(7); + } + await handler.close(); + }); + + it("refuses pre-ack with -32603 'Subscription limit reached' when at capacity", async () => { + const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0, maxSubscriptions: 1 }); + const first = await handler.fetch(listenRequest(1, { toolsListChanged: true })); + expect(first.headers.get('Content-Type')).toBe('text/event-stream'); + const second = await handler.fetch(listenRequest(2, { toolsListChanged: true })); + expect(second.headers.get('Content-Type')).toContain('application/json'); + const body = (await second.json()) as { error: { code: number; message: string }; id: unknown }; + expect(body.error.code).toBe(-32_603); + expect(body.error.message).toBe('Subscription limit reached'); + expect(body.id).toBe(2); + await first.body!.cancel(); + await handler.close(); + }); + + it("rejects with -32602 when params.notifications is absent (spec marks 'notifications' REQUIRED)", async () => { + const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0 }); + const response = await handler.fetch( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 9, method: 'subscriptions/listen', params: { _meta: ENVELOPE } }) + }) + ); + expect(response.headers.get('Content-Type')).toContain('application/json'); + const body = (await response.json()) as { error: { code: number; message: string }; id: unknown }; + expect(body.error.code).toBe(-32_602); + expect(body.error.message).toContain("'notifications' is required"); + expect(body.id).toBe(9); + await handler.close(); + }); + + it('handler.close() emits the empty subscriptions/listen result, then closes the stream (graceful-close signal)', async () => { + const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0 }); + const response = await handler.fetch(listenRequest(1, { toolsListChanged: true })); + const reader = response.body!.getReader(); + // First frame is the ack. + await reader.read(); + await handler.close(); + // Graceful-close termination: the SubscriptionsListenResult is the + // final SSE frame, then the stream ends. + let resultFrame: unknown; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + const text = new TextDecoder().decode(value); + const dataLine = text.split('\n').find(l => l.startsWith('data: ')); + if (dataLine) { + const message = JSON.parse(dataLine.slice(6)) as Record; + if ('result' in message) resultFrame = message; + } + } + expect(resultFrame).toEqual({ + jsonrpc: '2.0', + id: 1, + result: { resultType: 'complete', _meta: { 'io.modelcontextprotocol/subscriptionId': 1 } } + }); + }); + + it('legacy-classified listen never reaches the entry listen router (no ack delivered)', async () => { + const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0 }); + // No envelope claim → classified legacy → dispatched through the + // stateless fallback's Server, where `subscriptions/listen` is not in + // the 2025 registry → −32601 in-band (the legacy transport may stream + // it as a single SSE frame). + const response = await handler.fetch( + 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: 'subscriptions/listen', + params: { notifications: { toolsListChanged: true } } + }) + }) + ); + const text = await response.text(); + expect(text).not.toContain('notifications/subscriptions/acknowledged'); + expect(text).toContain('-32601'); + await handler.close(); + }); +}); diff --git a/packages/server/test/server/createMcpHandlerStatelessLiteral.test.ts b/packages/server/test/server/createMcpHandlerStatelessLiteral.test.ts new file mode 100644 index 0000000000..4aac72549e --- /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 fallback 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..f4bdbc27c6 --- /dev/null +++ b/packages/server/test/server/discover.test.ts @@ -0,0 +1,210 @@ +/** + * `server/discover` machinery + era-aware supported-version list semantics: + * + * - the handler is installed ONLY when the server's supported-versions list + * carries a modern (2026-07-28+) revision; default servers keep answering + * -32601 byte-identically to the deployed fleet + * - the advertisement is modern-only (DV-30) and carries the + * listChanged/subscribe-class capabilities (the spec keeps the bits at + * 2026-07-28; A11 rider discharged with the subscriptions/listen milestone) + * - 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. + * + * Era is instance state: an inbound `server/discover` is served only by a + * modern-era instance (the method is physically absent from the legacy + * registry). Production marking of modern instances is owned by the + * server-entry milestone; these tests mark instances through the + * package-internal hook the entry will use, 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, 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 }); + 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('advertises listChanged/subscribe-class capabilities (A11 rider discharged: subscriptions/listen is served)', 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 + } + ); + 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({ listChanged: true }); + expect(result.capabilities.prompts).toEqual({ listChanged: true }); + expect(result.capabilities.resources).toEqual({ listChanged: true, subscribe: true }); + expect(result.capabilities.logging).toEqual({}); + expect(result.capabilities.completions).toEqual({}); + + 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 advertised = discoverAdvertisedCapabilities(capabilities); + expect(advertised).toEqual({ tools: { listChanged: true }, resources: { subscribe: true, listChanged: true } }); + // No mutation / aliasing of the input. + expect(advertised).not.toBe(capabilities); + 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 }); + 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 }); + 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 }); + 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/eraParityErrorShapes.test.ts b/packages/server/test/server/eraParityErrorShapes.test.ts new file mode 100644 index 0000000000..a87b5b9b6e --- /dev/null +++ b/packages/server/test/server/eraParityErrorShapes.test.ts @@ -0,0 +1,248 @@ +/** + * 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); + // The encode seam selects the wire code: a handler-thrown −32002 is + // emitted as −32602 on BOTH eras (no era branch preserves −32002). + expect(legacy.error).toMatchObject({ code: -32_602, 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/inputRequired.test.ts b/packages/server/test/server/inputRequired.test.ts new file mode 100644 index 0000000000..efce872842 --- /dev/null +++ b/packages/server/test/server/inputRequired.test.ts @@ -0,0 +1,455 @@ +/** + * Server-side multi-round-trip seam (M4.1): + * + * - a handler for tools/call, prompts/get, or resources/read returns an + * input-required result on a 2026-07-28-classified request and it reaches + * the wire as `resultType: 'input_required'` (validateToolOutput and the + * tools/call result schema are skipped for it; cache fields are never + * stamped on it); + * - the guards: at-least-one re-check for hand-built results, the per-embedded + * -request `-32021` capability check against the request's OWN envelope + * capabilities, the server-bug guard (non-multi-round-trip methods, and any + * method on a 2025-era request, never put a mis-typed result on the wire); + * - a UrlElicitationRequiredError escaping a handler on the modern era fails + * LOUDLY (clear steer to inputRequired.elicitUrl(...), never converted) — + * `-32042` never reaches the 2026-07-28 wire — while 2025-era traffic keeps + * today's `-32042` behavior; + * - the push-style APIs loud-fail on 2026-era requests with the + * `inputRequired(...)` steer surfaced through the tools/call catch-all, with + * zero wire traffic emitted for the attempted server→client request; + * - the write-once re-entry: a retried request's `inputResponses` reach the + * handler via ctx and the final result passes full validation. + */ +import type { + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResultResponse +} from '@modelcontextprotocol/core'; +import { + acceptedContent, + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + InMemoryTransport, + inputRequired, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY, + setNegotiatedProtocolVersion, + UrlElicitationRequiredError +} from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; +import * as z from 'zod/v4'; + +import { McpServer } from '../../src/server/mcp.js'; +import type { ServerOptions } from '../../src/server/server.js'; +import { Server } from '../../src/server/server.js'; + +const MODERN = '2026-07-28'; + +const envelope = (clientCapabilities: Record = {}) => ({ + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'mrtr-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: clientCapabilities +}); + +async function wire(server: McpServer | Server, options?: { era?: 'modern' | 'legacy' }) { + 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(); + // Era is instance state: a serving entry binds the instance modern; for + // these unit tests we bind directly via the package-internal setter (the + // way createMcpHandler/serveStdio do). + if (options?.era === 'modern') { + setNegotiatedProtocolVersion(server instanceof Server ? server : server.server, MODERN); + } + + 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 modernToolCall = ( + id: number, + name: string, + args: Record = {}, + options?: { clientCapabilities?: Record; extraParams?: Record } +): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { + _meta: envelope(options?.clientCapabilities ?? {}), + name, + arguments: args, + ...options?.extraParams + } +}); + +const legacyInitialize = (id: number): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'initialize', + params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'legacy-client', version: '1.0.0' } } +}); + +function resultOf(message: JSONRPCMessage): Record { + return (message as JSONRPCResultResponse).result as unknown as Record; +} + +function errorOf(message: JSONRPCMessage): { code: number; message: string; data?: unknown } { + return (message as JSONRPCErrorResponse).error; +} + +describe('input-required returns on the 2026-07-28 era', () => { + it('a write-once tool returning inputRequired() reaches the wire as input_required and completes on the retry', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool( + 'deploy', + { inputSchema: z.object({ env: z.string() }), outputSchema: z.object({ deployed: z.boolean() }) }, + async ({ env }, ctx) => { + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (!confirmed?.confirm) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: `Deploy to ${env}?`, + requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } } } + }) + }, + requestState: 'opaque-deploy-state' + }); + } + return { content: [{ type: 'text', text: 'deployed' }], structuredContent: { deployed: true } }; + } + ); + const { request, close } = await wire(server, { era: 'modern' }); + + // First leg: input_required goes out, with no cache stamping and the + // structured-content requirement skipped. + const first = resultOf( + await request(modernToolCall(1, 'deploy', { env: 'prod' }, { clientCapabilities: { elicitation: { form: {} } } })) + ); + expect(first.resultType).toBe('input_required'); + expect(first.requestState).toBe('opaque-deploy-state'); + expect(first.inputRequests).toMatchObject({ confirm: { method: 'elicitation/create' } }); + expect(first.ttlMs).toBeUndefined(); + expect(first.cacheScope).toBeUndefined(); + expect(first.content).toBeUndefined(); + + // Retry leg (fresh id, responses + byte-exact echo): full validation + // applies to the completing result, which is stamped 'complete'. + const second = resultOf( + await request( + modernToolCall( + 2, + 'deploy', + { env: 'prod' }, + { + clientCapabilities: { elicitation: { form: {} } }, + extraParams: { + inputResponses: { confirm: { action: 'accept', content: { confirm: true } } }, + requestState: 'opaque-deploy-state' + } + } + ) + ) + ); + expect(second.resultType).toBe('complete'); + expect(second.structuredContent).toEqual({ deployed: true }); + + await close(); + }); + + it('prompts/get and resources/read handlers can return input_required (no catch-all rewraps it)', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { prompts: {}, resources: {} } }); + server.registerPrompt('wizard', { argsSchema: z.object({}) }, async () => inputRequired({ requestState: 'prompt-state' })); + server.registerResource('secret', 'file:///secret.txt', {}, async () => inputRequired({ requestState: 'resource-state' })); + const { request, close } = await wire(server, { era: 'modern' }); + + const promptResult = resultOf( + await request({ + jsonrpc: '2.0', + id: 1, + method: 'prompts/get', + params: { _meta: envelope(), name: 'wizard', arguments: {} } + }) + ); + expect(promptResult.resultType).toBe('input_required'); + expect(promptResult.requestState).toBe('prompt-state'); + + const resourceResult = resultOf( + await request({ + jsonrpc: '2.0', + id: 2, + method: 'resources/read', + params: { _meta: envelope(), uri: 'file:///secret.txt' } + }) + ); + expect(resourceResult.resultType).toBe('input_required'); + expect(resourceResult.requestState).toBe('resource-state'); + expect(resourceResult.ttlMs).toBeUndefined(); + + await close(); + }); +}); + +describe('guards', () => { + it('hand-built results missing both inputRequests and requestState fail loudly (at-least-one re-check)', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('broken', { inputSchema: z.object({}) }, async () => ({ resultType: 'input_required' }) as never); + const { request, close } = await wire(server, { era: 'modern' }); + + const answer = await request(modernToolCall(1, 'broken')); + expect(errorOf(answer).code).toBe(-32_603); + expect(JSON.stringify(answer)).not.toContain('"resultType":"input_required"'); + + await close(); + }); + + it('checks every embedded request against the capabilities the request itself declared (-32021 on violation)', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('ask', { inputSchema: z.object({}) }, async () => + inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ message: 'OK?', requestedSchema: { type: 'object', properties: {} } }) + } + }) + ); + server.registerTool('open-url', { inputSchema: z.object({}) }, async () => + inputRequired({ + inputRequests: { + auth: inputRequired.elicitUrl({ message: 'Sign in', url: 'https://example.com' }) + } + }) + ); + const { request, close } = await wire(server, { era: 'modern' }); + + // No elicitation capability declared on the request → -32021 naming + // the form sub-capability the embedded form-mode elicitation needs. + const noCapability = await request(modernToolCall(1, 'ask', {}, { clientCapabilities: {} })); + expect(errorOf(noCapability).code).toBe(-32_021); + expect(errorOf(noCapability).data).toMatchObject({ requiredCapabilities: { elicitation: { form: {} } } }); + + // Form-mode capability declared → the same tool is served. + const withCapability = await request(modernToolCall(2, 'ask', {}, { clientCapabilities: { elicitation: { form: {} } } })); + expect(resultOf(withCapability).resultType).toBe('input_required'); + + // URL-mode embedded request requires elicitation.url specifically. + const urlWithoutUrlCapability = await request( + modernToolCall(3, 'open-url', {}, { clientCapabilities: { elicitation: { form: {} } } }) + ); + expect(errorOf(urlWithoutUrlCapability).code).toBe(-32_021); + expect(errorOf(urlWithoutUrlCapability).data).toMatchObject({ requiredCapabilities: { elicitation: { url: {} } } }); + + // Form-mode embedded request toward a URL-only client → -32021: modes + // are sub-capabilities and the server must not send an undeclared one. + const formTowardUrlOnly = await request(modernToolCall(4, 'ask', {}, { clientCapabilities: { elicitation: { url: {} } } })); + expect(errorOf(formTowardUrlOnly).code).toBe(-32_021); + expect(errorOf(formTowardUrlOnly).data).toMatchObject({ requiredCapabilities: { elicitation: { form: {} } } }); + + // A bare `elicitation: {}` declaration is read as form support (the + // pre-mode meaning of a bare declaration) → served. + const bareElicitation = await request(modernToolCall(5, 'ask', {}, { clientCapabilities: { elicitation: {} } })); + expect(resultOf(bareElicitation).resultType).toBe('input_required'); + + await close(); + }); + + it('a 2025-era request never sees an input_required result: the server fails loudly instead (server-bug guard)', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('deploy', { inputSchema: z.object({}) }, async () => inputRequired({ requestState: 'state' })); + const { request, close } = await wire(server); + + await request(legacyInitialize(1)); + const answer = await request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'deploy', arguments: {} } }); + expect(errorOf(answer).code).toBe(-32_603); + // The mis-typed result never reaches the wire: the answer is an error, not a result. + expect((answer as { result?: unknown }).result).toBeUndefined(); + + await close(); + }); + + it('non-multi-round-trip methods can never emit input_required (server-bug guard)', async () => { + const server = new Server({ name: 's', version: '1.0.0' }, { capabilities: { completions: {} } }); + server.setRequestHandler('completion/complete', async () => ({ resultType: 'input_required', requestState: 's' }) as never); + const { request, close } = await wire(server, { era: 'modern' }); + + const answer = await request({ + jsonrpc: '2.0', + id: 1, + method: 'completion/complete', + params: { + _meta: envelope(), + ref: { type: 'ref/prompt', name: 'p' }, + argument: { name: 'a', value: 'v' } + } + }); + expect(errorOf(answer).code).toBe(-32_603); + // The mis-typed result never reaches the wire: the answer is an error, not a result. + expect((answer as { result?: unknown }).result).toBeUndefined(); + + await close(); + }); +}); + +describe('UrlElicitationRequiredError (the 2025-era -32042 idiom)', () => { + const URL_PARAMS = { mode: 'url' as const, message: 'Sign in to continue', elicitationId: 'elicit-7', url: 'https://example.com/auth' }; + + function buildUrlThrowingServer() { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('protected', { inputSchema: z.object({}) }, async () => { + throw new UrlElicitationRequiredError([URL_PARAMS]); + }); + return server; + } + + it('fails LOUDLY on a 2026-era request with a clear inputRequired.elicitUrl(...) steer — never converted, never -32042', async () => { + const { request, close } = await wire(buildUrlThrowingServer(), { era: 'modern' }); + + const answer = await request(modernToolCall(1, 'protected', {}, { clientCapabilities: { elicitation: { url: {} } } })); + expect(errorOf(answer).code).toBe(-32_603); + expect(errorOf(answer).message).toContain('inputRequired.elicitUrl'); + expect(JSON.stringify(answer)).not.toContain('"resultType":"input_required"'); + // The -32042 error code never appears on the 2026-07-28 wire (the steer + // text mentions it for migration; the wire error code is InternalError). + expect(JSON.stringify(answer)).not.toContain('"code":-32042'); + + await close(); + }); + + it('keeps the exact -32042 behavior for 2025-era traffic', async () => { + const { request, close } = await wire(buildUrlThrowingServer()); + + await request(legacyInitialize(1)); + const answer = await request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'protected', arguments: {} } }); + const error = errorOf(answer); + expect(error.code).toBe(-32_042); + expect(error.data).toEqual({ elicitations: [URL_PARAMS] }); + + await close(); + }); +}); + +describe('requestState.verify hook', () => { + function buildServer(options?: ServerOptions) { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} }, ...options }); + const handler = vi.fn(async () => ({ content: [{ type: 'text' as const, text: 'ok' }] })); + server.registerTool('deploy', { inputSchema: z.object({}) }, handler); + return { server, handler }; + } + + const reentry = (id: number, requestState?: string) => + modernToolCall(id, 'deploy', {}, { extraParams: requestState === undefined ? {} : { requestState } }); + + it('is called with the echoed state and the handler context, before the handler', async () => { + const seen: Array<{ state: string; method: string }> = []; + const { server, handler } = buildServer({ + requestState: { verify: (state, ctx) => void seen.push({ state, method: ctx.mcpReq.method }) } + }); + const { request, close } = await wire(server, { era: 'modern' }); + + const answer = resultOf(await request(reentry(1, 'signed-state'))); + expect(seen).toEqual([{ state: 'signed-state', method: 'tools/call' }]); + expect(handler).toHaveBeenCalledOnce(); + expect(answer.content).toEqual([{ type: 'text', text: 'ok' }]); + + await close(); + }); + + it('a throw becomes the frozen -32602 wire error (not an isError tool result); the reason goes to onerror only', async () => { + const { server, handler } = buildServer({ + requestState: { + verify: () => { + throw new Error('HMAC mismatch — granular reason'); + } + } + }); + const onerror = vi.fn(); + server.server.onerror = onerror; + const { request, close } = await wire(server, { era: 'modern' }); + + const answer = await request(reentry(1, 'tampered')); + // Real JSON-RPC error (above the tools/call funnel), not a result. + expect((answer as { result?: unknown }).result).toBeUndefined(); + const error = errorOf(answer); + expect(error.code).toBe(-32_602); + expect(error.message).toBe('Invalid or expired requestState'); + expect(error.data).toEqual({ reason: 'invalid_request_state' }); + // The granular reason never reaches the wire — onerror only. + expect(JSON.stringify(answer)).not.toContain('HMAC mismatch'); + expect(onerror).toHaveBeenCalledOnce(); + expect(String(onerror.mock.calls[0]?.[0])).toContain('HMAC mismatch'); + expect(handler).not.toHaveBeenCalled(); + + await close(); + }); + + it('is not called when the request carries no requestState', async () => { + const verify = vi.fn(); + const { server, handler } = buildServer({ requestState: { verify } }); + const { request, close } = await wire(server, { era: 'modern' }); + + const answer = resultOf(await request(reentry(1))); + expect(verify).not.toHaveBeenCalled(); + expect(handler).toHaveBeenCalledOnce(); + expect(answer.content).toEqual([{ type: 'text', text: 'ok' }]); + + await close(); + }); + + it('not configured → today’s behavior (raw passthrough; the handler reads the state itself)', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + let seen: string | undefined; + server.registerTool('deploy', { inputSchema: z.object({}) }, async (_args, ctx) => { + seen = ctx.mcpReq.requestState; + return { content: [{ type: 'text', text: 'ok' }] }; + }); + const { request, close } = await wire(server, { era: 'modern' }); + + const answer = resultOf(await request(reentry(1, 'raw-state'))); + expect(seen).toBe('raw-state'); + expect(answer.content).toEqual([{ type: 'text', text: 'ok' }]); + + await close(); + }); +}); + +describe('push-style APIs on 2026-era requests', () => { + it('ctx.mcpReq.elicitInput rejects before any wire traffic and the catch-all surfaces the inputRequired() steer as isError', async () => { + const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('legacy-style', { inputSchema: z.object({}) }, async (_args, ctx) => { + const answer = await ctx.mcpReq.elicitInput({ message: 'Name?', requestedSchema: { type: 'object', properties: {} } }); + return { content: [{ type: 'text', text: JSON.stringify(answer) }] }; + }); + const { request, inbound, close } = await wire(server, { era: 'modern' }); + + const answer = await request(modernToolCall(1, 'legacy-style', {}, { clientCapabilities: { elicitation: { form: {} } } })); + const result = resultOf(answer); + expect(result.isError).toBe(true); + const text = JSON.stringify(result.content); + expect(text).toContain('inputRequired('); + + // Zero wire traffic for the attempted server→client request: the only + // message the peer ever received is the tools/call response itself. + expect(inbound.filter(message => (message as { method?: string }).method === 'elicitation/create')).toHaveLength(0); + expect(inbound).toHaveLength(1); + + 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..764bd99518 --- /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_022); + 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/legacyDefaultServing.test.ts b/packages/server/test/server/legacyDefaultServing.test.ts new file mode 100644 index 0000000000..877349894c --- /dev/null +++ b/packages/server/test/server/legacyDefaultServing.test.ts @@ -0,0 +1,113 @@ +/** + * Q10-L2 golden pin: a hand-constructed `McpServer` connected to a long-lived + * transport (the shape of every existing stdio server) serves a scripted 2025 + * session with today's exact result shapes and zero 2026 vocabulary on the + * wire — and keeps answering `server/discover` with `-32601`, byte-identical + * to the deployed fleet. Hand-constructed instances serve only the 2025 era; + * serving the 2026-07-28 revision on stdio goes through the `serveStdio` + * entry (covered in `serveStdio.test.ts`). + */ +import type { JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/core'; +import { InMemoryTransport, isJSONRPCErrorResponse, isJSONRPCResultResponse, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { McpServer } from '../../src/server/mcp.js'; + +function buildServer() { + const server = new McpServer( + { name: 'legacy-default-test-server', version: '1.0.0' }, + { capabilities: { tools: {} }, instructions: 'test instructions' } + ); + 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('Q10-L2: a hand-constructed server 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: 'legacy-default-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/legacyStatelessFallback.test.ts b/packages/server/test/server/legacyStatelessFallback.test.ts new file mode 100644 index 0000000000..a125168c78 --- /dev/null +++ b/packages/server/test/server/legacyStatelessFallback.test.ts @@ -0,0 +1,184 @@ +/** + * legacyStatelessFallback — the entry's default legacy serving, 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/listOrdering.test.ts b/packages/server/test/server/listOrdering.test.ts new file mode 100644 index 0000000000..d5c793453e --- /dev/null +++ b/packages/server/test/server/listOrdering.test.ts @@ -0,0 +1,51 @@ +/** + * SF-02 — deterministic list ordering across requests. + * + * The spec recommends servers return tools/prompts/resources in a stable, + * deterministic order across requests when the underlying set has not changed. + * `McpServer` registries are plain string-keyed objects, so iteration is + * insertion order; this test pins that ordering at the wire so a registry + * refactor cannot quietly change it. + */ +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'; + +const LEGACY = { classification: { era: 'legacy' as const } }; + +const list = async (server: McpServer, method: string, key: string): Promise => { + const response = await invoke(server, { jsonrpc: '2.0', id: 1, method, params: {} }, LEGACY); + const body = (await response.json()) as { result: Record> }; + return body.result[key]!.map(item => item.name); +}; + +describe('McpServer list ordering', () => { + it('tools/list, prompts/list and resources/list each return in registration order, stable across calls', async () => { + const server = new McpServer({ name: 'ordering', version: '0' }); + + // Non-sorted registration order so neither alphabetic nor reverse would mask it. + const toolOrder = ['gamma', 'alpha', 'mu', 'beta']; + for (const name of toolOrder) { + server.registerTool(name, { inputSchema: z.object({}) }, async () => ({ content: [] })); + } + + const promptOrder = ['second', 'first', 'third']; + for (const name of promptOrder) { + server.registerPrompt(name, {}, async () => ({ messages: [] })); + } + + const resourceOrder = ['c', 'a', 'b']; + for (const name of resourceOrder) { + server.registerResource(name, `mem://${name}`, {}, async () => ({ contents: [] })); + } + + expect(await list(server, 'tools/list', 'tools')).toEqual(toolOrder); + expect(await list(server, 'tools/list', 'tools')).toEqual(toolOrder); + expect(await list(server, 'prompts/list', 'prompts')).toEqual(promptOrder); + expect(await list(server, 'prompts/list', 'prompts')).toEqual(promptOrder); + expect(await list(server, 'resources/list', 'resources')).toEqual(resourceOrder); + expect(await list(server, 'resources/list', 'resources')).toEqual(resourceOrder); + }); +}); diff --git a/packages/server/test/server/lowLevelLegacyWrap.test.ts b/packages/server/test/server/lowLevelLegacyWrap.test.ts new file mode 100644 index 0000000000..121057b4dc --- /dev/null +++ b/packages/server/test/server/lowLevelLegacyWrap.test.ts @@ -0,0 +1,114 @@ +/** + * SEP-2106 legacy projection lives in the wire codec — proven on a low-level + * `Server` (NOT `McpServer`). The handler is era-blind and returns a + * non-object `outputSchema` / `structuredContent` directly; on a 2025-era + * connection the WIRE BYTES carry the wrapped `{type:'object', + * properties:{result:…}}` schema and the wrapped `{result:}` + * structured content. Nothing in `mcp.ts` (or any server-side code) re-derives + * the wrap — the only era branch is the codec. + */ +import type { JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/core'; +import { InMemoryTransport, isJSONRPCResultResponse, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { Server } from '../../src/server/server.js'; + +async function wire(server: Server) { + const [peerTx, serverTx] = InMemoryTransport.createLinkedPair(); + const waiters = new Map void>(); + peerTx.onmessage = 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 }; +} + +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('SEP-2106: the 2025 wire codec owns the legacy {result:…} wrap (low-level Server)', () => { + it("encodeResult('tools/list') wraps a non-object outputSchema root on the wire", async () => { + const server = new Server({ name: 's', version: '1' }, { capabilities: { tools: {} } }); + // Era-blind handler: returns the natural (2026-vocabulary) array-rooted outputSchema. + server.setRequestHandler('tools/list', () => ({ + tools: [{ name: 'x', inputSchema: { type: 'object' as const }, outputSchema: { type: 'array', items: { type: 'number' } } }] + })); + const { request, notify } = await wire(server); + await request(initializeRequest(1)); + await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + const reply = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); + if (!isJSONRPCResultResponse(reply)) throw new Error(`expected result, got ${JSON.stringify(reply)}`); + const tools = (reply.result as { tools: ReadonlyArray<{ name: string; outputSchema?: unknown }> }).tools; + // Wire bytes carry the 2025-era projection — wrapped, not the natural schema. + expect(tools[0]?.outputSchema).toEqual({ + type: 'object', + properties: { result: { type: 'array', items: { type: 'number' } } }, + required: ['result'] + }); + }); + + it('projectCallToolResult wraps structuredContent as {result:…} when the advertised schema has a non-object root', async () => { + const server = new Server({ name: 's', version: '1' }, { capabilities: { tools: {} } }); + const advertised = { type: 'array', items: { type: 'number' } } as const; + server.setRequestHandler('tools/list', () => ({ + tools: [{ name: 'x', inputSchema: { type: 'object' as const }, outputSchema: advertised }] + })); + // Low-level handlers route the result-side projection through the codec themselves + // (McpServer's tools/call handler does the same call). The codec is the ONLY place + // the era branch lives. + server.setRequestHandler('tools/call', () => + server.projectCallToolResult({ content: [], structuredContent: [1, 2, 3] }, advertised) + ); + const { request, notify } = await wire(server); + await request(initializeRequest(1)); + await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + const reply = await request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'x', arguments: {} } }); + if (!isJSONRPCResultResponse(reply)) throw new Error(`expected result, got ${JSON.stringify(reply)}`); + const result = reply.result as { structuredContent?: unknown; content?: ReadonlyArray<{ type: string; text?: string }> }; + expect(result.structuredContent).toEqual({ result: [1, 2, 3] }); + // The era-agnostic SEP-2106 §4.3 TextContent auto-append also lives behind the codec. + expect(result.content).toContainEqual({ type: 'text', text: JSON.stringify([1, 2, 3]) }); + }); + + it('projectCallToolResult wraps a non-object structuredContent value as {result:…} REGARDLESS of advertised schema (schema-less tool)', async () => { + // A schema-less tool (no `outputSchema` advertised) returning a non-object + // `structuredContent` would otherwise ship wire-illegal bytes on the 2025 + // era — the wire shape requires `structuredContent` to be an object. The + // projection wraps on value shape alone, so the result is always + // wire-legal even when there is no schema to consult. + const server = new Server({ name: 's', version: '1' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/list', () => ({ + tools: [{ name: 'x', inputSchema: { type: 'object' as const } }] + })); + server.setRequestHandler('tools/call', () => + server.projectCallToolResult({ content: [], structuredContent: [1, 2, 3] }, undefined) + ); + const { request, notify } = await wire(server); + await request(initializeRequest(1)); + await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + const reply = await request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'x', arguments: {} } }); + if (!isJSONRPCResultResponse(reply)) throw new Error(`expected result, got ${JSON.stringify(reply)}`); + const result = reply.result as { structuredContent?: unknown; content?: ReadonlyArray<{ type: string; text?: string }> }; + expect(result.structuredContent).toEqual({ result: [1, 2, 3] }); + expect(result.content).toContainEqual({ type: 'text', text: JSON.stringify([1, 2, 3]) }); + }); +}); diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts index 322b615353..9adb866ff9 100644 --- a/packages/server/test/server/mcp.compat.test.ts +++ b/packages/server/test/server/mcp.compat.test.ts @@ -127,3 +127,23 @@ describe('InferRawShape', () => { expectTypeOf().toEqualTypeOf<{ a: string; b?: string | undefined }>(); }); }); + +describe('SEP-2106: registerTool with non-object outputSchema (type-level)', () => { + it('accepts z.array(z.number()) as outputSchema and a number[] structuredContent compiles', () => { + const server = new McpServer({ name: 's', version: '1' }); + server.registerTool('arr', { inputSchema: z.object({ n: z.number() }), outputSchema: z.array(z.number()) }, async ({ n }) => ({ + content: [], + structuredContent: [n, n + 1] satisfies number[] + })); + // NOTE (SEP-2106 PR-B verification item): the OutputArgs generic on registerTool is + // captured but does NOT currently flow into the callback's return type — ToolCallback's + // SendResultT is `CallToolResult | InputRequiredResult` (structuredContent: unknown), so + // a wrong-typed structuredContent ALSO compiles. Runtime validation (validateToolOutput) + // is the guard. Tightening the generic is out of this commit's scope. + server.registerTool('arr-loose', { outputSchema: z.array(z.number()) }, async () => ({ + content: [], + structuredContent: 'not-an-array' // compiles: structuredContent is `unknown` + })); + expectTypeOf().toMatchTypeOf>>>(); + }); +}); diff --git a/packages/server/test/server/mcpParamValidation.test.ts b/packages/server/test/server/mcpParamValidation.test.ts new file mode 100644 index 0000000000..0b8b02158a --- /dev/null +++ b/packages/server/test/server/mcpParamValidation.test.ts @@ -0,0 +1,137 @@ +/** + * SEP-2243 server-side `Mcp-Param-*` validation at the createMcpHandler entry + * (protocol revision 2026-07-28). + * + * Pre-dispatch ladder rung: a `tools/call` whose `Mcp-Param-{Name}` headers + * disagree with the body `arguments` (or are missing for a present body value, + * or carry an invalid Base64 sentinel) is rejected `400` / `-32020` with the + * same `HeaderMismatch` shape the inbound classifier emits for the + * standard-header cross-checks. A `null`/absent body value passes regardless + * of the header (the spec's "server MUST NOT expect" rows). The + * registration-time declaration-validity check warns on invalid declarations. + */ +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + encodeMcpParamValue, + PROTOCOL_VERSION_META_KEY +} from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { fromJsonSchema } from '../../src/fromJsonSchema.js'; +import { createMcpHandler } from '../../src/server/createMcpHandler.js'; +import { McpServer } from '../../src/server/mcp.js'; + +const MODERN = '2026-07-28'; +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'param-test', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +const REGION_INPUT_SCHEMA = { + type: 'object', + properties: { region: { type: 'string', 'x-mcp-header': 'Region' }, query: { type: 'string' } } +} as const; + +function makeFactory(): () => McpServer { + return () => { + const s = new McpServer({ name: 'param-server', version: '1.0.0' }); + s.registerTool('route', { inputSchema: fromJsonSchema<{ region?: string; query?: string }>(REGION_INPUT_SCHEMA) }, async args => ({ + content: [{ type: 'text', text: `routed ${args.region ?? ''}` }] + })); + return s; + }; +} + +function call(args: Record, paramHeaders: Record = {}): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-protocol-version': MODERN, + 'mcp-method': 'tools/call', + 'mcp-name': 'route', + ...paramHeaders + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 7, + method: 'tools/call', + params: { name: 'route', arguments: args, _meta: ENVELOPE } + }) + }); +} + +describe('SEP-2243 Mcp-Param-* server validation (createMcpHandler, modern era)', () => { + it('a matching Mcp-Param header passes and the call dispatches', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch(call({ region: 'us-west1', query: 'x' }, { 'Mcp-Param-Region': 'us-west1' })); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('routed us-west1'); + }); + + it('a Base64-sentinel header decodes and matches', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch(call({ region: 'Hello, 世界' }, { 'Mcp-Param-Region': encodeMcpParamValue('Hello, 世界') })); + expect(response.status).toBe(200); + }); + + it('a disagreeing header is rejected 400/-32020 (HeaderMismatch) and reports the rejection', async () => { + const onerror = vi.fn(); + const handler = createMcpHandler(makeFactory(), { onerror }); + const response = await handler.fetch(call({ region: 'us-west1' }, { 'Mcp-Param-Region': 'eu' })); + expect(response.status).toBe(400); + const body = (await response.json()) as { id: unknown; error: { code: number; data?: { mismatch?: { header?: string } } } }; + expect(body.error.code).toBe(-32_020); + expect(body.error.data?.mismatch?.header).toBe('Mcp-Param-Region'); + expect(body.id).toBe(7); + expect(onerror).toHaveBeenCalled(); + }); + + // sep-2243-server-reject-missing-required (globally-untested manifest check). + it('a missing header for a present body value is rejected 400/-32020', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch(call({ region: 'us-west1' })); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: number } }; + expect(body.error.code).toBe(-32_020); + }); + + // sep-2243-server-not-expect-null (globally-untested manifest check). + it('a null/absent body value passes regardless of any stray header', async () => { + const handler = createMcpHandler(makeFactory()); + const r1 = await handler.fetch(call({ query: 'x' }, { 'Mcp-Param-Region': 'whatever' })); + const r2 = await handler.fetch(call({ region: null as unknown as string, query: 'x' })); + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + }); + + it('an invalid Base64 sentinel is rejected 400/-32020', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch(call({ region: 'Hello' }, { 'Mcp-Param-Region': '=?base64?SGVsbG8?=' })); + expect(response.status).toBe(400); + expect(((await response.json()) as { error: { code: number } }).error.code).toBe(-32_020); + }); +}); + +describe('SEP-2243 registerTool declaration-validity check', () => { + it('warns on an invalid x-mcp-header declaration at registration time', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const s = new McpServer({ name: 'warn-server', version: '1.0.0' }); + s.registerTool( + 'bad', + { + inputSchema: fromJsonSchema({ + type: 'object', + properties: { a: { type: 'object', 'x-mcp-header': 'Data' } as Record } + }) + }, + async () => ({ content: [] }) + ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("tool 'bad' carries an invalid x-mcp-header")); + warn.mockRestore(); + }); +}); 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..69a9088d7f --- /dev/null +++ b/packages/server/test/server/perRequestTransport.test.ts @@ -0,0 +1,388 @@ +/** + * 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_022); + 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_022); + }); +}); + +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); + // The encode seam maps −32002 → −32602 on the wire; what this test + // pins is that the error stays IN-BAND on HTTP 200. + expect(errorOf(await response.json())).toMatchObject({ code: -32_602, 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_022, '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_022); + }); + + 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/requestStateCodec.test.ts b/packages/server/test/server/requestStateCodec.test.ts new file mode 100644 index 0000000000..bc823c6690 --- /dev/null +++ b/packages/server/test/server/requestStateCodec.test.ts @@ -0,0 +1,178 @@ +/** + * `createRequestStateCodec` — the opt-in HMAC-SHA256 sealing helper for the + * multi-round-trip `requestState` (SEP-2322). Pure unit tests of the codec; + * the seam-level wiring (`ServerOptions.requestState.verify`) is covered in + * `inputRequired.test.ts`. + */ +import type { ServerContext } from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { createRequestStateCodec } from '../../src/server/requestStateCodec.js'; +import type { ServerOptions } from '../../src/server/server.js'; + +const KEY = crypto.getRandomValues(new Uint8Array(32)); + +// Minimal stand-in for the bits of ServerContext the codec's `bind` callback +// reads — the codec itself never inspects ctx beyond passing it to `bind`. +const fakeCtx = (method: string, clientId?: string) => + ({ mcpReq: { method }, http: clientId === undefined ? undefined : { authInfo: { clientId } } }) as unknown as ServerContext; + +describe('createRequestStateCodec', () => { + it('round-trips a JSON payload', async () => { + const codec = createRequestStateCodec<{ step: string; n: number }>({ key: KEY }); + const wire = await codec.mint({ step: 'confirm', n: 42 }); + expect(wire).toMatch(/^v1\.[-A-Za-z0-9_]+\.[-A-Za-z0-9_]+$/); + const payload = await codec.verify(wire, fakeCtx('tools/call')); + expect(payload).toEqual({ step: 'confirm', n: 42 }); + }); + + it('rejects a tampered body with reason "mac"', async () => { + const codec = createRequestStateCodec({ key: KEY }); + const wire = await codec.mint({ step: 'confirm' }); + // Flip a base64url character in the body segment. + const dot = wire.lastIndexOf('.'); + const tampered = `${wire.slice(0, 4)}${wire[4] === 'A' ? 'B' : 'A'}${wire.slice(5, dot)}${wire.slice(dot)}`; + await expect(codec.verify(tampered, fakeCtx('tools/call'))).rejects.toThrow('mac'); + }); + + it('rejects a tampered MAC with reason "mac"', async () => { + const codec = createRequestStateCodec({ key: KEY }); + const wire = await codec.mint({ step: 'confirm' }); + const tampered = `${wire.slice(0, -2)}${wire.at(-2) === 'A' ? 'B' : 'A'}${wire.at(-1)}`; + await expect(codec.verify(tampered, fakeCtx('tools/call'))).rejects.toThrow('mac'); + }); + + it('rejects values minted under a different key', async () => { + const codecA = createRequestStateCodec({ key: KEY }); + const codecB = createRequestStateCodec({ key: crypto.getRandomValues(new Uint8Array(32)) }); + const wire = await codecA.mint({ step: 'confirm' }); + await expect(codecB.verify(wire, fakeCtx('tools/call'))).rejects.toThrow('mac'); + }); + + it('rejects malformed envelopes (missing prefix / missing segments)', async () => { + const codec = createRequestStateCodec({ key: KEY }); + await expect(codec.verify('not-v1.body.mac', fakeCtx('tools/call'))).rejects.toThrow('malformed'); + await expect(codec.verify('v1.bodyonly', fakeCtx('tools/call'))).rejects.toThrow('malformed'); + await expect(codec.verify('v1..mac', fakeCtx('tools/call'))).rejects.toThrow('malformed'); + await expect(codec.verify('', fakeCtx('tools/call'))).rejects.toThrow('malformed'); + }); + + it('rejects an expired value with reason "expired"', async () => { + // ttlSeconds: -1 stamps an exp already in the past; the MAC still + // verifies (we minted it), so the rejection is the expiry check. + const codec = createRequestStateCodec({ key: KEY, ttlSeconds: -1 }); + const wire = await codec.mint({ step: 'confirm' }); + await expect(codec.verify(wire, fakeCtx('tools/call'))).rejects.toThrow('expired'); + }); + + describe('context binding', () => { + const bind = (ctx: ServerContext) => + `${ctx.mcpReq.method}\0${(ctx.http?.authInfo as { clientId?: string } | undefined)?.clientId ?? ''}`; + + it('round-trips when the binding value matches', async () => { + const codec = createRequestStateCodec<{ step: string }>({ key: KEY, bind }); + const wire = await codec.mint({ step: 'confirm' }, fakeCtx('tools/call', 'alice')); + const payload = await codec.verify(wire, fakeCtx('tools/call', 'alice')); + expect(payload).toEqual({ step: 'confirm' }); + }); + + it('rejects with reason "bind" when the binding value differs — message is opaque', async () => { + const codec = createRequestStateCodec({ key: KEY, bind }); + const wire = await codec.mint({ step: 'confirm' }, fakeCtx('tools/call', 'alice')); + const rejection = await codec.verify(wire, fakeCtx('tools/call', 'mallory')).catch((e: Error) => e); + expect(rejection).toBeInstanceOf(Error); + // The thrown reason is a fixed code; neither principal identifier + // appears in the message (so onerror logging cannot leak them). + expect((rejection as Error).message).toBe('bind'); + expect((rejection as Error).message).not.toContain('alice'); + expect((rejection as Error).message).not.toContain('mallory'); + }); + + it('mint without ctx throws when bind is configured', async () => { + const codec = createRequestStateCodec({ key: KEY, bind }); + await expect(codec.mint({ step: 'confirm' })).rejects.toThrow(TypeError); + }); + + it('does not embed the raw binding value in the minted state (stored as a keyed tag)', async () => { + const codec = createRequestStateCodec({ key: KEY, bind }); + const wire = await codec.mint({ step: 'confirm' }, fakeCtx('tools/call', 'alice')); + // The body segment is base64url(JSON) — decode it and confirm neither + // component of the raw `bind(ctx)` value (`tools/call\0alice`) appears. + // It is stored as a keyed HMAC tag instead, so the client cannot read + // the principal identifier out of the signed-not-encrypted envelope. + const body = wire.slice('v1.'.length, wire.lastIndexOf('.')); + const decoded = new TextDecoder().decode( + Uint8Array.from(atob(body.replaceAll('-', '+').replaceAll('_', '/')), c => c.codePointAt(0)!) + ); + expect(decoded).not.toContain('alice'); + expect(decoded).not.toContain('tools/call'); + expect(JSON.parse(decoded)).toHaveProperty('b'); + }); + + it('rejects a bound token when bind is unconfigured (fail-closed on config drift)', async () => { + const bound = createRequestStateCodec({ key: KEY, bind }); + const unbound = createRequestStateCodec({ key: KEY }); + const wire = await bound.mint({ step: 'confirm' }, fakeCtx('tools/call', 'alice')); + // Same key, MAC verifies — but the unconfigured instance must + // refuse a token that carries a `b` field rather than silently + // dropping the principal-binding guarantee. + await expect(unbound.verify(wire, fakeCtx('tools/call', 'alice'))).rejects.toThrow('bind'); + }); + }); + + it('snapshots a Uint8Array key at construction (caller mutation has no effect)', async () => { + const mutable = crypto.getRandomValues(new Uint8Array(32)); + const codec = createRequestStateCodec({ key: mutable }); + // Zero the caller's buffer AFTER construction (standard secret-hygiene). + // The codec already owns its own copy, so mint+verify still agree on + // the original key — and a second codec built from the zeroed buffer + // is a DIFFERENT key. + mutable.fill(0); + const wire = await codec.mint({ ok: true }); + expect(await codec.verify(wire, fakeCtx('tools/call'))).toEqual({ ok: true }); + const codecZero = createRequestStateCodec({ key: new Uint8Array(32) }); + await expect(codecZero.verify(wire, fakeCtx('tools/call'))).rejects.toThrow('mac'); + }); + + it('binds the version prefix into the MAC (a transplanted body.mac under a different prefix fails)', async () => { + const codec = createRequestStateCodec({ key: KEY }); + const wire = await codec.mint({ ok: true }); + // The MAC covers `"v1." + body`. The verifier hard-gates the prefix, + // so to assert the MAC binding we recompute it manually with `"v2."` + // over the same body and confirm the codec rejects that pair. + const body = wire.slice('v1.'.length, wire.lastIndexOf('.')); + const cryptoKey = await crypto.subtle.importKey('raw', KEY, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + const v2Mac = new Uint8Array(await crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(`v2.${body}`))); + const b64url = (b: Uint8Array) => + btoa(String.fromCodePoint(...b)) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/, ''); + // Same body, MAC computed for a v2 prefix, presented under v1 — MAC fails. + await expect(codec.verify(`v1.${body}.${b64url(v2Mac)}`, fakeCtx('tools/call'))).rejects.toThrow('mac'); + }); + + it('throws RangeError on a key shorter than 32 bytes', () => { + expect(() => createRequestStateCodec({ key: new Uint8Array(31) })).toThrow(RangeError); + expect(() => createRequestStateCodec({ key: 'short' })).toThrow(RangeError); + }); + + it('throws RangeError on a non-finite ttlSeconds (Infinity/NaN would mint never-expiring tokens)', () => { + expect(() => createRequestStateCodec({ key: KEY, ttlSeconds: Infinity })).toThrow(RangeError); + expect(() => createRequestStateCodec({ key: KEY, ttlSeconds: Number.NaN })).toThrow(RangeError); + }); + + it('accepts a 32-character string key (UTF-8 length)', async () => { + const codec = createRequestStateCodec({ key: 'a'.repeat(32) }); + const wire = await codec.mint({ ok: true }); + expect(await codec.verify(wire, fakeCtx('tools/call'))).toEqual({ ok: true }); + }); + + it('codec.verify is directly assignable to ServerOptions.requestState.verify', () => { + // Type-level guard: the seam hook accepts any return so a verifier + // that also yields the decoded payload drops in directly. + const codec = createRequestStateCodec({ key: KEY }); + const opts: ServerOptions = { requestState: { verify: codec.verify } }; + expect(opts.requestState?.verify).toBe(codec.verify); + }); +}); diff --git a/packages/server/test/server/serveStdio.test.ts b/packages/server/test/server/serveStdio.test.ts new file mode 100644 index 0000000000..aced8d3b9f --- /dev/null +++ b/packages/server/test/server/serveStdio.test.ts @@ -0,0 +1,813 @@ +/** + * `serveStdio` — the connection-pinned stdio entry: + * + * - the opening exchange selects the era exactly once; ONE factory instance + * is pinned for the connection lifetime and serves only that era; + * - a legacy opening (`initialize`, or any claim-less message) pins a 2025 + * instance that serves the session exactly as a hand-wired stdio server + * does today (zero 2026 vocabulary on the wire — the per-connection leak + * test); + * - a valid modern envelope opening pins a 2026-07-28 instance (era-written + * by the entry, modern-only handlers installed); + * - a `server/discover` probe is answered without pinning; the next message + * either pins the modern era or falls back to a fresh legacy instance + * (probe instance discarded) when the client returns to `initialize`; + * - once the modern era is pinned, a late claim-less `initialize` is answered + * with the unsupported-protocol-version error naming the supported + * revisions; + * - `legacy: 'reject'` answers legacy openings with the same error and never + * pins a legacy instance; + * - malformed and unsupported envelope claims are answered by the entry, + * consistent with the HTTP entry's treatment, without pinning. + */ +import type { + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + MessageExtraInfo, + Transport +} from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + InMemoryTransport, + isJSONRPCErrorResponse, + isJSONRPCResultResponse, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY, + SdkError, + SdkErrorCode +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import type { McpServerFactory } from '../../src/server/createMcpHandler.js'; +import { McpServer } from '../../src/server/mcp.js'; +import type { ServeStdioOptions } from '../../src/server/serveStdio.js'; +import { serveStdio } from '../../src/server/serveStdio.js'; + +const MODERN = '2026-07-28'; + +/** 2026-era vocabulary that must never leak onto a connection pinned to the 2025 era. */ +const FORBIDDEN_2026_VOCABULARY = ['2026', 'discover', 'envelope', 'modern', 'era', 'resultType', 'io.modelcontextprotocol']; + +const envelope = (overrides?: Record) => ({ + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'serve-stdio-test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {}, + ...overrides +}); + +const initializeRequest = (id: number | string, requestedVersion = LATEST_PROTOCOL_VERSION): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'initialize', + params: { + protocolVersion: requestedVersion, + capabilities: {}, + clientInfo: { name: 'legacy-client', version: '1.0.0' } + } +}); + +/** A factory that records every construction (era + product) and registers one echo tool. */ +function trackingFactory() { + const eras: Array<'legacy' | 'modern'> = []; + const closed: boolean[] = []; + const factory = (ctx: { era: 'legacy' | 'modern' }) => { + const index = eras.length; + eras.push(ctx.era); + closed.push(false); + const server = new McpServer( + { name: 'serve-stdio-test-server', version: '1.0.0' }, + { capabilities: { tools: {} }, instructions: 'serve-stdio test instructions' } + ); + server.registerTool('echo', { description: 'Echoes the input text', inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + server.server.onclose = () => { + closed[index] = true; + }; + return server; + }; + return { factory, eras, closed }; +} + +/** Boots the entry on one side of an in-memory pair with the given factory and returns raw drivers for the peer side. */ +async function startEntryWith(factory: McpServerFactory, options?: Omit) { + const [peerTx, wireTx] = 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 peerTx.start(); + + const errors: Error[] = []; + const handle = serveStdio(factory, { transport: wireTx, onerror: error => void errors.push(error), ...options }); + + 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, 20)); + + return { handle, request, notify, flush, inbound, errors, peerTx }; +} + +/** Boots the entry with a fresh tracking factory (the default harness for most tests). */ +async function startEntry(options?: Omit) { + const { factory, eras, closed } = trackingFactory(); + return { ...(await startEntryWith(factory, options)), eras, closed }; +} + +describe('legacy opening (default legacy: serve)', () => { + it('pins one 2025-era instance for the connection and serves it exactly like a hand-wired stdio server', async () => { + const { handle, request, notify, inbound, eras } = await startEntry(); + + 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: 'serve-stdio-test-server', version: '1.0.0' }, + instructions: 'serve-stdio 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)) { + expect((list.result as { tools: Array<{ name: string }> }).tools.map(tool => tool.name)).toEqual(['echo']); + 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' }] }); + } + + // The era decision happened exactly once: one legacy instance, no probe instance. + expect(eras).toEqual(['legacy']); + + // Per-connection leak test: a claim-less server/discover on this + // 2025-pinned connection answers the same plain -32601 a deployed 2025 + // server answers, with zero 2026 vocabulary anywhere in the response. + const gate = await request({ jsonrpc: '2.0', id: 4, method: 'server/discover', params: {} }); + expect(isJSONRPCErrorResponse(gate)).toBe(true); + if (isJSONRPCErrorResponse(gate)) { + expect(gate.error).toEqual({ code: -32_601, message: 'Method not found' }); + } + + // Nothing the entry or the instance wrote on this connection carries 2026 wire vocabulary. + const wireBytes = JSON.stringify(inbound).toLowerCase(); + for (const term of FORBIDDEN_2026_VOCABULARY) { + expect(wireBytes).not.toContain(term.toLowerCase()); + } + + await handle.close(); + }); + + it('a claim-less non-initialize opening also pins the legacy era', async () => { + const { handle, request, eras } = await startEntry(); + + const list = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(list)).toBe(true); + expect(eras).toEqual(['legacy']); + + await handle.close(); + }); +}); + +describe('modern opening', () => { + it('a valid enveloped request pins one era-written 2026-07-28 instance', async () => { + const { handle, request, eras } = await startEntry(); + + const list = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(list)).toBe(true); + if (isJSONRPCResultResponse(list)) { + const result = list.result as { tools: Array<{ name: string }>; resultType?: string }; + expect(result.tools.map(tool => tool.name)).toEqual(['echo']); + expect(result.resultType).toBe('complete'); + } + expect(eras).toEqual(['modern']); + + const call = await request({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'modern leg' }, _meta: envelope() } + }); + expect(isJSONRPCResultResponse(call)).toBe(true); + if (isJSONRPCResultResponse(call)) { + expect((call.result as { content: unknown[] }).content).toEqual([{ type: 'text', text: 'modern leg' }]); + } + + await handle.close(); + }); + + it('an enveloped initialize is classified by its valid modern claim and answered with a plain -32601', async () => { + const { handle, request, eras } = await startEntry(); + + const response = await request({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { _meta: envelope() } }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + expect(response.error.code).toBe(-32_601); + expect(response.error.message).toBe('Method not found'); + expect(response.error.data).toBeUndefined(); + } + expect(eras).toEqual(['modern']); + + await handle.close(); + }); + + it('once the modern era is pinned, a late claim-less initialize answers -32022 naming the supported revisions', async () => { + const { handle, request } = await startEntry(); + + const list = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(list)).toBe(true); + + const init = await request(initializeRequest(2)); + expect(isJSONRPCErrorResponse(init)).toBe(true); + if (isJSONRPCErrorResponse(init)) { + expect(init.error.code).toBe(-32_022); + const data = init.error.data as { supported?: string[]; requested?: string }; + expect(data.supported).toContain(MODERN); + expect(data.requested).toBe(LATEST_PROTOCOL_VERSION); + } + + await handle.close(); + }); +}); + +describe('server/discover probe window', () => { + it('answers the probe from an optimistically built modern instance and pins modern when the client continues with the envelope', async () => { + const { handle, request, eras } = await startEntry(); + + const discover = await request({ jsonrpc: '2.0', id: 'probe-1', 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 call = await request({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'after probe' }, _meta: envelope() } + }); + expect(isJSONRPCResultResponse(call)).toBe(true); + if (isJSONRPCResultResponse(call)) { + expect((call.result as { content: unknown[] }).content).toEqual([{ type: 'text', text: 'after probe' }]); + } + + // The probe instance IS the pinned instance: the factory ran once. + expect(eras).toEqual(['modern']); + + await handle.close(); + }); + + it('discover followed by initialize falls back to a fresh legacy instance and discards the probe instance', async () => { + const { handle, request, eras, closed } = await startEntry(); + + const discover = await request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(discover)).toBe(true); + + // The client found no mutually supported modern revision and falls + // back to the 2025 handshake on the same connection. + const init = await request(initializeRequest(2)); + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + } + + // The optimistic modern instance was discarded; the legacy session is + // served end to end by the second (legacy) instance. + expect(eras).toEqual(['modern', 'legacy']); + expect(closed[0]).toBe(true); + expect(closed[1]).toBe(false); + + const list = await request({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(list)).toBe(true); + if (isJSONRPCResultResponse(list)) { + expect(JSON.stringify(list)).not.toContain('resultType'); + } + + await handle.close(); + }); + + it('answers the probe even when the fallback initialize is pipelined immediately behind it', async () => { + const { handle, request, flush, inbound, errors, eras } = await startEntry(); + + // The client does not wait for the DiscoverResult before falling back: + // both messages are on the wire back to back. The probe must still be + // answered (never silently dropped) and the legacy session served. + const discoverPromise = request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + const initPromise = request(initializeRequest(2)); + + const [discover, init] = await Promise.all([discoverPromise, initPromise]); + expect(isJSONRPCResultResponse(discover)).toBe(true); + if (isJSONRPCResultResponse(discover)) { + expect((discover.result as { supportedVersions?: string[] }).supportedVersions).toEqual([MODERN]); + } + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + } + // The probe answer reached the wire before the fallback's handshake answer. + expect(inbound.indexOf(discover)).toBeLessThan(inbound.indexOf(init)); + expect(eras).toEqual(['modern', 'legacy']); + + // The legacy session continues normally and nothing was dropped or reported. + const list = await request({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(list)).toBe(true); + await flush(); + expect(errors).toEqual([]); + + await handle.close(); + }); + + it('a repeated server/discover probe is answered by the same probe instance and a later initialize still falls back to legacy', async () => { + const { handle, request, eras, closed } = await startEntry(); + + const first = await request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(first)).toBe(true); + + const second = await request({ jsonrpc: '2.0', id: 'probe-2', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(second)).toBe(true); + if (isJSONRPCResultResponse(second)) { + expect((second.result as { supportedVersions?: string[] }).supportedVersions).toEqual([MODERN]); + } + + // Both probes were answered by the single optimistic instance; the + // connection is still inside the negotiation window. + expect(eras).toEqual(['modern']); + + // The fallback handshake is still served by a fresh legacy instance. + const init = await request(initializeRequest(3)); + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + } + expect(eras).toEqual(['modern', 'legacy']); + expect(closed[0]).toBe(true); + expect(closed[1]).toBe(false); + + await handle.close(); + }); + + it('an enveloped notification during the probe window does not pin the era and a later initialize still falls back to legacy', async () => { + const { handle, request, notify, flush, eras, closed, errors } = await startEntry(); + + const discover = await request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(discover)).toBe(true); + + // The client cancels its probe (for example on a local timeout) with + // an enveloped notification before falling back to the 2025 + // handshake. The notification is delivered to the probe instance but + // does not commit the connection to the modern era. + await notify({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId: 'probe-1', reason: 'probe timed out', _meta: envelope() } + }); + await flush(); + + const init = await request(initializeRequest(2)); + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + } + + // The fallback handshake was served by a fresh legacy instance and + // the probe instance was discarded; nothing was reported as dropped. + expect(eras).toEqual(['modern', 'legacy']); + expect(closed[0]).toBe(true); + expect(closed[1]).toBe(false); + expect(errors).toEqual([]); + + await handle.close(); + }); + + it('a pipelined cancellation of the probe followed by initialize still falls back to a working legacy session', async () => { + const { handle, request, notify, flush, eras, closed, errors } = await startEntry(); + + // The client pipelines all three messages without waiting for any + // answer: the probe, an enveloped cancellation naming the probe id + // (which aborts the in-flight discover handler, so the probe may + // legitimately never be answered), and the fallback 2025 handshake. + // The cancelled probe must not hold the connection: the handshake is + // answered and the legacy session is fully usable. + void request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + void notify({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId: 'probe-1', reason: 'negotiation aborted', _meta: envelope() } + }); + const init = await request(initializeRequest(2)); + expect(isJSONRPCResultResponse(init)).toBe(true); + if (isJSONRPCResultResponse(init)) { + expect((init.result as { protocolVersion?: string }).protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + } + + // The probe instance was discarded and the fallback is served end to + // end by a fresh legacy instance. + expect(eras).toEqual(['modern', 'legacy']); + expect(closed[0]).toBe(true); + expect(closed[1]).toBe(false); + + const list = await request({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} }); + expect(isJSONRPCResultResponse(list)).toBe(true); + await flush(); + expect(errors).toEqual([]); + + await handle.close(); + }); + + it('an enveloped non-discover request after the probe still pins the modern era', async () => { + const { handle, request, eras } = await startEntry(); + + const discover = await request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(discover)).toBe(true); + + const call = await request({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'commit' }, _meta: envelope() } + }); + expect(isJSONRPCResultResponse(call)).toBe(true); + + // The enveloped request committed the connection: a later claim-less + // initialize is rejected instead of falling back to a legacy instance. + const init = await request(initializeRequest(3)); + expect(isJSONRPCErrorResponse(init)).toBe(true); + if (isJSONRPCErrorResponse(init)) { + expect(init.error.code).toBe(-32_022); + } + // The probe instance is the pinned instance: the factory ran exactly once. + expect(eras).toEqual(['modern']); + + await handle.close(); + }); + + it('a repeated server/discover probe followed by an enveloped request pins the modern era', async () => { + const { handle, request, eras } = await startEntry(); + + const first = await request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(first)).toBe(true); + + const second = await request({ jsonrpc: '2.0', id: 'probe-2', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(second)).toBe(true); + + const call = await request({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'after repeated probe' }, _meta: envelope() } + }); + expect(isJSONRPCResultResponse(call)).toBe(true); + if (isJSONRPCResultResponse(call)) { + expect((call.result as { content: unknown[] }).content).toEqual([{ type: 'text', text: 'after repeated probe' }]); + } + + // The probe instance is the pinned instance: the factory ran exactly once. + expect(eras).toEqual(['modern']); + + await handle.close(); + }); +}); + +describe("legacy: 'reject'", () => { + it('answers a legacy opening with -32022 naming the supported modern revisions and never pins a legacy instance', async () => { + const { handle, request, eras } = await startEntry({ legacy: 'reject' }); + + const init = await request(initializeRequest(1)); + expect(isJSONRPCErrorResponse(init)).toBe(true); + if (isJSONRPCErrorResponse(init)) { + expect(init.error.code).toBe(-32_022); + const data = init.error.data as { supported?: string[]; requested?: string }; + expect(data.supported).toContain(MODERN); + expect(data.requested).toBe(LATEST_PROTOCOL_VERSION); + } + expect(eras).toEqual([]); + + // A modern opening on the same connection is still served afterwards. + const list = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(list)).toBe(true); + expect(eras).toEqual(['modern']); + + await handle.close(); + }); + + it('drops a claim-less notification without a response', async () => { + const { handle, notify, flush, inbound, eras } = await startEntry({ legacy: 'reject' }); + + await notify({ jsonrpc: '2.0', method: 'notifications/initialized' }); + await flush(); + + expect(inbound).toHaveLength(0); + expect(eras).toEqual([]); + + await handle.close(); + }); +}); + +describe('malformed and unsupported envelope claims (entry-answered, never pinned)', () => { + it('a present claim with a malformed envelope answers -32602 naming the envelope problem', async () => { + const { handle, request, eras } = await startEntry(); + + const response = await request({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN } } + }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + expect(response.error.code).toBe(-32_602); + expect(response.error.message).toContain('Invalid _meta envelope'); + } + expect(eras).toEqual([]); + + // The connection is not pinned by the rejected opening: a valid + // modern opening afterwards is served normally. + const list = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(list)).toBe(true); + expect(eras).toEqual(['modern']); + + await handle.close(); + }); + + it('a valid claim naming an unsupported revision answers -32022 with the supported list', async () => { + const { handle, request, eras } = await startEntry(); + + const response = await request({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: envelope({ [PROTOCOL_VERSION_META_KEY]: '2099-01-01' }) } + }); + expect(isJSONRPCErrorResponse(response)).toBe(true); + if (isJSONRPCErrorResponse(response)) { + expect(response.error.code).toBe(-32_022); + const data = (response as JSONRPCErrorResponse).error.data as { supported?: string[]; requested?: string }; + expect(data.supported).toContain(MODERN); + expect(data.requested).toBe('2099-01-01'); + } + expect(eras).toEqual([]); + + await handle.close(); + }); +}); + +describe('factory or connect failure during the opening exchange (entry-answered, never pinned)', () => { + it('answers a legacy opening with -32603 when the factory throws, reports the error, and leaves the connection unpinned', async () => { + const { factory: workingFactory, eras } = trackingFactory(); + let failures = 1; + const factory: McpServerFactory = ctx => { + if (failures > 0) { + failures -= 1; + throw new Error('factory failed to build an instance'); + } + return workingFactory(ctx); + }; + const { handle, request, flush, errors } = await startEntryWith(factory); + + const init = await request(initializeRequest(1)); + expect(isJSONRPCErrorResponse(init)).toBe(true); + if (isJSONRPCErrorResponse(init)) { + expect(init.error.code).toBe(-32_603); + expect(init.error.message).toBe('Internal server error'); + } + await flush(); + expect(errors.some(error => error.message.includes('factory failed to build an instance'))).toBe(true); + expect(eras).toEqual([]); + + // The failed opening did not pin the connection: a retried handshake + // on the same connection is served by a fresh legacy instance. + const retry = await request(initializeRequest(2)); + expect(isJSONRPCResultResponse(retry)).toBe(true); + expect(eras).toEqual(['legacy']); + + await handle.close(); + }); + + it('answers a modern opening with -32603 when connecting the instance fails and leaves the connection unpinned', async () => { + const { factory: workingFactory, eras } = trackingFactory(); + let failures = 1; + const factory: McpServerFactory = ctx => { + const product = workingFactory(ctx); + if (failures > 0) { + failures -= 1; + product.connect = () => Promise.reject(new Error('instance connect failed')); + } + return product; + }; + const { handle, request, flush, errors } = await startEntryWith(factory); + + const list = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCErrorResponse(list)).toBe(true); + if (isJSONRPCErrorResponse(list)) { + expect(list.error.code).toBe(-32_603); + expect(list.error.message).toBe('Internal server error'); + } + await flush(); + expect(errors.some(error => error.message.includes('instance connect failed'))).toBe(true); + // The factory ran but nothing was pinned: the next modern opening is + // served by a freshly connected instance. + expect(eras).toEqual(['modern']); + + const retry = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: { _meta: envelope() } }); + expect(isJSONRPCResultResponse(retry)).toBe(true); + expect(eras).toEqual(['modern', 'modern']); + + await handle.close(); + }); + + it('answers a server/discover probe with -32603 when the factory rejects and keeps the negotiation window open', async () => { + const { factory: workingFactory, eras } = trackingFactory(); + let failures = 1; + const factory: McpServerFactory = ctx => { + if (failures > 0) { + failures -= 1; + return Promise.reject(new Error('factory failed to build an instance')); + } + return workingFactory(ctx); + }; + const { handle, request, flush, errors } = await startEntryWith(factory); + + const discover = await request({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + expect(isJSONRPCErrorResponse(discover)).toBe(true); + if (isJSONRPCErrorResponse(discover)) { + expect(discover.error.code).toBe(-32_603); + expect(discover.error.message).toBe('Internal server error'); + } + await flush(); + expect(errors.some(error => error.message.includes('factory failed to build an instance'))).toBe(true); + expect(eras).toEqual([]); + + // The failed probe did not pin anything: the connection is still in + // the negotiation window and a fallback handshake is served normally. + const init = await request(initializeRequest(2)); + expect(isJSONRPCResultResponse(init)).toBe(true); + expect(eras).toEqual(['legacy']); + + await handle.close(); + }); +}); + +describe('a close racing the opening factory', () => { + /** + * A factory that suspends until released and exposes what happens to its + * product afterwards: whether it was closed, and every message that is + * delivered to it after it has been connected. + */ + function gatedObservableFactory() { + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + let entered!: () => void; + const constructionStarted = new Promise(resolve => { + entered = resolve; + }); + const eras: Array<'legacy' | 'modern'> = []; + const productClosed: boolean[] = []; + const delivered: JSONRPCMessage[] = []; + const factory: McpServerFactory = async ctx => { + const index = eras.length; + eras.push(ctx.era); + productClosed.push(false); + entered(); + await gate; + const server = new McpServer({ name: 'serve-stdio-test-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.server.onclose = () => { + productClosed[index] = true; + }; + const realConnect = server.connect.bind(server); + server.connect = async (transport: Transport) => { + await realConnect(transport); + const forward = transport.onmessage; + transport.onmessage = (message: JSONRPCMessage, extra?: MessageExtraInfo) => { + delivered.push(message); + forward?.(message, extra); + }; + }; + return server; + }; + return { factory, constructionStarted, release, eras, productClosed, delivered }; + } + + it('handle.close() during the legacy factory build stays closed: the late instance is closed and never delivered to', async () => { + const { factory, constructionStarted, release, eras, productClosed, delivered } = gatedObservableFactory(); + const { handle, flush, inbound, peerTx } = await startEntryWith(factory); + + // The opening handshake arrives and the entry starts building the + // legacy instance; the connection is closed while the factory is + // still mid-construction. + void peerTx.send(initializeRequest(1)); + await constructionStarted; + await handle.close(); + + // The factory resolves only after the connection is gone. + release(); + await flush(); + + // The connection stays closed: the late-resolved instance is closed, + // the opening message is never delivered to it, nothing further + // reaches the wire, and no other instance is built. + expect(eras).toEqual(['legacy']); + expect(productClosed).toEqual([true]); + expect(delivered).toEqual([]); + expect(inbound).toEqual([]); + }); + + it('handle.close() during the probe-instance build does not resurrect the negotiation window', async () => { + const { factory, constructionStarted, release, eras, productClosed, delivered } = gatedObservableFactory(); + const { handle, flush, inbound, peerTx } = await startEntryWith(factory); + + void peerTx.send({ jsonrpc: '2.0', id: 'probe-1', method: 'server/discover', params: { _meta: envelope() } }); + await constructionStarted; + await handle.close(); + + release(); + await flush(); + + expect(eras).toEqual(['modern']); + expect(productClosed).toEqual([true]); + expect(delivered).toEqual([]); + expect(inbound).toEqual([]); + }); +}); + +describe('outbound era gate on a modern-pinned connection', () => { + it('a handler calling ctx.mcpReq.requestSampling gets the typed era error locally, with zero sampling wire traffic', async () => { + let observed: unknown; + const factory: McpServerFactory = () => { + const server = new McpServer({ name: 'serve-stdio-test-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('sample', { description: 'Tries to request sampling', inputSchema: z.object({}) }, async (_args, ctx) => { + try { + await ctx.mcpReq.requestSampling({ messages: [], maxTokens: 1 }); + } catch (error) { + observed = error; + } + return { content: [{ type: 'text', text: 'handled locally' }] }; + }); + return server; + }; + const { handle, request, inbound } = await startEntryWith(factory); + + const call = await request({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'sample', arguments: {}, _meta: envelope() } + }); + expect(isJSONRPCResultResponse(call)).toBe(true); + if (isJSONRPCResultResponse(call)) { + expect((call.result as { content: unknown[] }).content).toEqual([{ type: 'text', text: 'handled locally' }]); + } + + // The outbound era gate fired locally with the typed error… + expect(observed).toBeInstanceOf(SdkError); + expect((observed as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + // …and nothing beyond the tool-call answer ever reached the wire: no + // sampling/createMessage request was written to the client. + expect(inbound).toEqual([call]); + + await handle.close(); + }); +}); + +describe('teardown', () => { + it('handle.close() closes the pinned instance and the wire transport', async () => { + const { handle, request, closed, peerTx } = await startEntry(); + + const init = await request(initializeRequest(1)); + expect(isJSONRPCResultResponse(init)).toBe(true); + + let peerClosed = false; + peerTx.onclose = () => { + peerClosed = true; + }; + + await handle.close(); + expect(closed[0]).toBe(true); + expect(peerClosed).toBe(true); + }); +}); diff --git a/packages/server/test/server/serveStdioListen.test.ts b/packages/server/test/server/serveStdioListen.test.ts new file mode 100644 index 0000000000..af5e15c6ca --- /dev/null +++ b/packages/server/test/server/serveStdioListen.test.ts @@ -0,0 +1,246 @@ +/** + * `serveStdio` — entry-handled `subscriptions/listen` on the stdio entry. + * + * Covers ack-first on the single channel, subscription-id stamping, the + * pinned instance's send*ListChanged() feeding the connection's listen + * router (era-gated; legacy unchanged), inbound cancel hardening, and the + * graceful-close path (one empty `subscriptions/listen` result per + * subscription id on `handle.close()`). + */ +import type { JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, Transport } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + InMemoryTransport, + PROTOCOL_VERSION_META_KEY, + SUBSCRIPTION_ID_META_KEY +} from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { StdioListenRouter } from '../../src/server/listenRouter.js'; +import { McpServer } from '../../src/server/mcp.js'; +import { serveStdio } from '../../src/server/serveStdio.js'; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: '2026-07-28', + [CLIENT_INFO_META_KEY]: { name: 'stdio-listen-test', version: '1' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +function listenReq(id: number | string, filter: Record): JSONRPCRequest { + return { jsonrpc: '2.0', id, method: 'subscriptions/listen', params: { _meta: ENVELOPE, notifications: filter } }; +} + +async function bootModern(options?: { maxSubscriptions?: number }) { + const [peerTx, wireTx] = InMemoryTransport.createLinkedPair(); + const inbound: JSONRPCMessage[] = []; + peerTx.onmessage = m => inbound.push(m); + await peerTx.start(); + + let server!: McpServer; + const handle = serveStdio( + () => { + server = new McpServer({ name: 's', version: '1' }); + server.registerTool('a', { inputSchema: z.object({}) }, async () => ({ content: [] })); + return server; + }, + { transport: wireTx as Transport, ...options } + ); + // Pin modern with a tools/list (any non-discover enveloped request). + await peerTx.send({ jsonrpc: '2.0', id: 'pin', method: 'tools/list', params: { _meta: ENVELOPE } }); + await new Promise(r => setTimeout(r, 10)); + inbound.length = 0; + const flush = () => new Promise(r => setTimeout(r, 10)); + const send = (m: JSONRPCRequest | JSONRPCNotification) => peerTx.send(m); + return { handle, server: () => server, inbound, send, flush }; +} + +describe('serveStdio — subscriptions/listen', () => { + it('ack is the first message after a listen request, stamped with the listen id verbatim', async () => { + const { handle, inbound, send, flush } = await bootModern(); + await send(listenReq(7, { toolsListChanged: true })); + await flush(); + expect(inbound).toHaveLength(1); + expect(inbound[0]).toEqual({ + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: 7 }, notifications: { toolsListChanged: true } } + }); + await handle.close(); + }); + + it("the pinned instance's sendToolListChanged() reaches only opted-in subscriptions, stamped per stream", async () => { + const { handle, server, inbound, send, flush } = await bootModern(); + await send(listenReq(1, { toolsListChanged: true })); + await send(listenReq(2, { promptsListChanged: true })); + await flush(); + inbound.length = 0; + // Mutate registration → McpServer fires sendToolListChanged(). + server().registerTool('b', { inputSchema: z.object({}) }, async () => ({ content: [] })); + await flush(); + expect(inbound).toHaveLength(1); + const note = inbound[0] as JSONRPCNotification; + expect(note.method).toBe('notifications/tools/list_changed'); + expect((note.params as { _meta: Record })._meta[SUBSCRIPTION_ID_META_KEY]).toBe(1); + await handle.close(); + }); + + it('drops change notifications no subscription opted in to (modern era never delivers unsolicited)', async () => { + const { handle, server, inbound, send, flush } = await bootModern(); + await send(listenReq(1, { promptsListChanged: true })); + await flush(); + inbound.length = 0; + server().sendToolListChanged(); + await flush(); + expect(inbound).toEqual([]); + await handle.close(); + }); + + it('inbound notifications/cancelled tears the subscription down; nothing further delivered (post-cancel hardening)', async () => { + const { handle, server, inbound, send, flush } = await bootModern(); + await send(listenReq(5, { toolsListChanged: true })); + await flush(); + inbound.length = 0; + await send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 5 } }); + await flush(); + server().sendToolListChanged(); + await flush(); + expect(inbound).toEqual([]); + await handle.close(); + }); + + it('handle.close() emits one empty subscriptions/listen result per active subscription id (graceful-close signal)', async () => { + const { handle, inbound, send, flush } = await bootModern(); + await send(listenReq('s1', { toolsListChanged: true })); + await send(listenReq('s2', { promptsListChanged: true })); + await flush(); + inbound.length = 0; + await handle.close(); + // The spec's SubscriptionsListenResult — one per subscription id, then + // the wire closes. No notifications/cancelled (the pre-#2953 path). + const results = inbound.filter(m => 'result' in m) as { id: unknown; result: unknown }[]; + expect(results.map(m => m.id)).toEqual(['s1', 's2']); + expect(results.map(m => m.result)).toEqual([ + { resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: 's1' } }, + { resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: 's2' } } + ]); + expect(inbound.some(m => (m as JSONRPCNotification).method === 'notifications/cancelled')).toBe(false); + }); + + it('refuses pre-ack with -32603 when at capacity', async () => { + const { handle, inbound, send, flush } = await bootModern({ maxSubscriptions: 1 }); + await send(listenReq(1, { toolsListChanged: true })); + await send(listenReq(2, { toolsListChanged: true })); + await flush(); + const err = inbound.find(m => 'error' in m) as { id: unknown; error: { code: number; message: string } } | undefined; + expect(err?.id).toBe(2); + expect(err?.error.code).toBe(-32_603); + expect(err?.error.message).toBe('Subscription limit reached'); + await handle.close(); + }); + + it("narrows the acknowledged filter against the pinned instance's declared capabilities", async () => { + // bootModern's factory registers a tool (so tools.listChanged is + // advertised) but no prompts/resources: a listen requesting all + // listChanged types must see only toolsListChanged honored. + const { handle, inbound, send, flush } = await bootModern(); + await send(listenReq(42, { toolsListChanged: true, promptsListChanged: true, resourcesListChanged: true })); + await flush(); + expect(inbound).toHaveLength(1); + expect(inbound[0]).toEqual({ + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: 42 }, notifications: { toolsListChanged: true } } + }); + await handle.close(); + }); + + it('rejects an entry-handled listen with -32602 when the per-request envelope is absent', async () => { + const { handle, inbound, send, flush } = await bootModern(); + // Connection is pinned modern; a later listen without the envelope + // claim must be rejected at the entry's envelope rung (no ack written). + await send({ jsonrpc: '2.0', id: 8, method: 'subscriptions/listen', params: { notifications: { toolsListChanged: true } } }); + await flush(); + expect(inbound).toHaveLength(1); + const err = inbound[0] as { id: unknown; error: { code: number; message: string } }; + expect(err.id).toBe(8); + expect(err.error.code).toBe(-32_602); + expect(err.error.message).toContain('Invalid _meta envelope'); + expect(inbound.some(m => (m as JSONRPCNotification).method === 'notifications/subscriptions/acknowledged')).toBe(false); + await handle.close(); + }); + + it('rejects an entry-handled listen claiming a revision the entry does not serve (unsupported-revision)', async () => { + const { handle, inbound, send, flush } = await bootModern(); + await send({ + jsonrpc: '2.0', + id: 9, + method: 'subscriptions/listen', + params: { + _meta: { ...ENVELOPE, [PROTOCOL_VERSION_META_KEY]: '2099-01-01' }, + notifications: { toolsListChanged: true } + } + }); + await flush(); + expect(inbound).toHaveLength(1); + const err = inbound[0] as { id: unknown; error: { code: number; message: string; data?: unknown } }; + expect(err.id).toBe(9); + // Same shape the opening classifier produces for an unsupported + // revision (ProtocolErrorCode.UnsupportedProtocolVersion). + expect(err.error.code).toBe(-32_022); + expect(err.error.data).toMatchObject({ requested: '2099-01-01' }); + expect(inbound.some(m => (m as JSONRPCNotification).method === 'notifications/subscriptions/acknowledged')).toBe(false); + await handle.close(); + }); + + it('legacy-era pinned connection passes change notifications through unchanged (2025 unsolicited delivery)', async () => { + const [peerTx, wireTx] = InMemoryTransport.createLinkedPair(); + const inbound: JSONRPCMessage[] = []; + peerTx.onmessage = m => inbound.push(m); + await peerTx.start(); + let server!: McpServer; + const handle = serveStdio( + () => { + server = new McpServer({ name: 's', version: '1' }); + server.registerTool('a', { inputSchema: z.object({}) }, async () => ({ content: [] })); + return server; + }, + { transport: wireTx as Transport } + ); + // Legacy opening. + await peerTx.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'c', version: '1' } } + }); + await new Promise(r => setTimeout(r, 10)); + await peerTx.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + await new Promise(r => setTimeout(r, 10)); + inbound.length = 0; + server.sendToolListChanged(); + await new Promise(r => setTimeout(r, 10)); + // 2025 unsolicited delivery: passed straight through, NO subscription-id stamp. + expect(inbound).toHaveLength(1); + const note = inbound[0] as JSONRPCNotification; + expect(note.method).toBe('notifications/tools/list_changed'); + expect((note.params as { _meta?: unknown } | undefined)?._meta).toBeUndefined(); + await handle.close(); + }); +}); + +describe('StdioListenRouter — capability gate', () => { + it('serve() throws before setServerCapabilities() (refuses to honor a filter without capabilities)', () => { + const router = new StdioListenRouter(); + expect(() => router.serve(listenReq(1, { toolsListChanged: true }))).toThrow(/before setServerCapabilities/); + // Once capabilities are supplied (as serveStdio does at modern-instance + // construction) the same call succeeds and narrows. + router.setServerCapabilities({ tools: { listChanged: true } }); + const ack = router.serve(listenReq(1, { toolsListChanged: true, promptsListChanged: true })); + expect(ack).toMatchObject({ + method: 'notifications/subscriptions/acknowledged', + params: { notifications: { toolsListChanged: true } } + }); + }); +}); diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index 0edcfd3af0..057bcfcbd9 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/upgrade-to-v2.md, + // "Wire tightening (every era)"): 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/packages/server/test/server/serverEventBus.test.ts b/packages/server/test/server/serverEventBus.test.ts new file mode 100644 index 0000000000..6f9feb1d7b --- /dev/null +++ b/packages/server/test/server/serverEventBus.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; + +import { + InMemoryServerEventBus, + createServerNotifier, + honoredSubset, + listenFilterAccepts, + serverEventToNotification +} from '../../src/server/serverEventBus.js'; + +describe('listenFilterAccepts', () => { + it('accepts only the change types the filter explicitly opted in to', () => { + const filter = { toolsListChanged: true as const }; + expect(listenFilterAccepts(filter, { kind: 'tools_list_changed' })).toBe(true); + expect(listenFilterAccepts(filter, { kind: 'prompts_list_changed' })).toBe(false); + expect(listenFilterAccepts(filter, { kind: 'resources_list_changed' })).toBe(false); + expect(listenFilterAccepts(filter, { kind: 'resource_updated', uri: 'file:///x' })).toBe(false); + }); + + it('treats false and absent identically (opt-in only on true)', () => { + expect(listenFilterAccepts({ toolsListChanged: false }, { kind: 'tools_list_changed' })).toBe(false); + expect(listenFilterAccepts({}, { kind: 'tools_list_changed' })).toBe(false); + }); + + it('matches resource_updated only on the exact opted-in URI', () => { + const filter = { resourceSubscriptions: ['file:///project/config.json'] }; + expect(listenFilterAccepts(filter, { kind: 'resource_updated', uri: 'file:///project/config.json' })).toBe(true); + expect(listenFilterAccepts(filter, { kind: 'resource_updated', uri: 'file:///other' })).toBe(false); + // Empty list = no resource updates accepted. + expect(listenFilterAccepts({ resourceSubscriptions: [] }, { kind: 'resource_updated', uri: 'file:///x' })).toBe(false); + // Absent = no resource updates accepted. + expect(listenFilterAccepts({}, { kind: 'resource_updated', uri: 'file:///x' })).toBe(false); + }); + + it('an empty filter accepts nothing (un-requested types are provably never delivered)', () => { + const filter = {}; + expect(listenFilterAccepts(filter, { kind: 'tools_list_changed' })).toBe(false); + expect(listenFilterAccepts(filter, { kind: 'prompts_list_changed' })).toBe(false); + expect(listenFilterAccepts(filter, { kind: 'resources_list_changed' })).toBe(false); + expect(listenFilterAccepts(filter, { kind: 'resource_updated', uri: 'file:///x' })).toBe(false); + }); +}); + +describe('honoredSubset', () => { + it('keeps only explicitly-true / non-empty fields', () => { + expect(honoredSubset({ toolsListChanged: true, promptsListChanged: false, resourceSubscriptions: ['file:///a'] })).toEqual({ + toolsListChanged: true, + resourceSubscriptions: ['file:///a'] + }); + }); + + it('returns an empty object for an all-absent / all-false filter', () => { + expect(honoredSubset({})).toEqual({}); + expect(honoredSubset({ toolsListChanged: false, resourceSubscriptions: [] })).toEqual({}); + }); + + it('does not alias the requested resourceSubscriptions array', () => { + const requested = { resourceSubscriptions: ['file:///a'] }; + const honored = honoredSubset(requested); + requested.resourceSubscriptions.push('file:///b'); + expect(honored.resourceSubscriptions).toEqual(['file:///a']); + }); + + it('narrows against the supplied server capabilities', () => { + const requested = { + toolsListChanged: true as const, + promptsListChanged: true as const, + resourcesListChanged: true as const, + resourceSubscriptions: ['file:///a'] + }; + // Only tools.listChanged advertised → only toolsListChanged honored. + expect(honoredSubset(requested, { tools: { listChanged: true } })).toEqual({ toolsListChanged: true }); + // resources.subscribe gates resourceSubscriptions; resources.listChanged gates resourcesListChanged. + expect(honoredSubset(requested, { resources: { subscribe: true } })).toEqual({ resourceSubscriptions: ['file:///a'] }); + expect(honoredSubset(requested, { resources: { listChanged: true } })).toEqual({ resourcesListChanged: true }); + // No relevant capability advertised → empty. + expect(honoredSubset(requested, {})).toEqual({}); + // Omitted capabilities → requested set honored as-is (back-compat). + expect(honoredSubset(requested)).toEqual(requested); + }); +}); + +describe('serverEventToNotification', () => { + it('maps each event kind onto its wire method', () => { + expect(serverEventToNotification({ kind: 'tools_list_changed' })).toEqual({ method: 'notifications/tools/list_changed' }); + expect(serverEventToNotification({ kind: 'prompts_list_changed' })).toEqual({ method: 'notifications/prompts/list_changed' }); + expect(serverEventToNotification({ kind: 'resources_list_changed' })).toEqual({ + method: 'notifications/resources/list_changed' + }); + expect(serverEventToNotification({ kind: 'resource_updated', uri: 'file:///a' })).toEqual({ + method: 'notifications/resources/updated', + params: { uri: 'file:///a' } + }); + }); +}); + +describe('InMemoryServerEventBus', () => { + it('delivers a published event to every registered listener', () => { + const bus = new InMemoryServerEventBus(); + const a: string[] = []; + const b: string[] = []; + bus.subscribe(e => a.push(e.kind)); + bus.subscribe(e => b.push(e.kind)); + bus.publish({ kind: 'tools_list_changed' }); + expect(a).toEqual(['tools_list_changed']); + expect(b).toEqual(['tools_list_changed']); + }); + + it('unsubscribe is idempotent and stops further delivery', () => { + const bus = new InMemoryServerEventBus(); + const seen: string[] = []; + const off = bus.subscribe(e => seen.push(e.kind)); + bus.publish({ kind: 'tools_list_changed' }); + off(); + off(); + bus.publish({ kind: 'prompts_list_changed' }); + expect(seen).toEqual(['tools_list_changed']); + expect(bus.listenerCount).toBe(0); + }); + + it('a throwing listener does not stop delivery to peers; error surfaces via onerror', () => { + const errors: Error[] = []; + const bus = new InMemoryServerEventBus(e => errors.push(e)); + const seen: string[] = []; + bus.subscribe(() => { + throw new Error('boom'); + }); + bus.subscribe(e => seen.push(e.kind)); + bus.publish({ kind: 'tools_list_changed' }); + expect(seen).toEqual(['tools_list_changed']); + expect(errors).toHaveLength(1); + expect(errors[0]!.message).toBe('boom'); + }); + + it('createServerNotifier publishes the matching event kind', () => { + const bus = new InMemoryServerEventBus(); + const seen: unknown[] = []; + bus.subscribe(e => seen.push(e)); + const notify = createServerNotifier(bus); + notify.toolsChanged(); + notify.promptsChanged(); + notify.resourcesChanged(); + notify.resourceUpdated('file:///a'); + expect(seen).toEqual([ + { kind: 'tools_list_changed' }, + { kind: 'prompts_list_changed' }, + { kind: 'resources_list_changed' }, + { kind: 'resource_updated', uri: 'file:///a' } + ]); + }); +}); diff --git a/packages/server/test/server/stdHeaderValidation.test.ts b/packages/server/test/server/stdHeaderValidation.test.ts new file mode 100644 index 0000000000..3c7f9238ac --- /dev/null +++ b/packages/server/test/server/stdHeaderValidation.test.ts @@ -0,0 +1,169 @@ +/** + * SEP-2243 standard-header server-side validation at the createMcpHandler + * entry (protocol revision 2026-07-28). + * + * The presence and `Mcp-Name` cross-check half of the standard-header rung, + * evaluated by the entry on a modern-classified request immediately after the + * body-primary classifier returns a modern route. A missing `Mcp-Method` + * header, a missing `Mcp-Name` header on a `tools/call` / `prompts/get` / + * `resources/read` request, an `Mcp-Name` value disagreeing with + * `params.name` / `params.uri`, and an invalid `Mcp-Name` Base64 sentinel are + * all rejected `400` / `-32020` (`HeaderMismatch`) on the + * `standard-header-validation` rung — the same shape the classifier already + * emits for the `MCP-Protocol-Version` and `Mcp-Method` mismatch cells on the + * edge `era-classification` rung. Legacy-era traffic is byte-unchanged. + */ +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + encodeMcpParamValue, + PROTOCOL_VERSION_META_KEY +} 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'; + +const MODERN = '2026-07-28'; +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'std-header-test', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +function makeFactory(): () => McpServer { + return () => { + const s = new McpServer({ name: 'std-header-server', version: '1.0.0' }); + s.registerTool('echo', { inputSchema: z.object({ text: z.string().optional() }) }, async ({ text }) => ({ + content: [{ type: 'text', text: text ?? 'ok' }] + })); + return s; + }; +} + +function modernRequest(method: string, params: Record, headers: Record = {}): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-protocol-version': MODERN, + ...headers + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 5, method, params: { ...params, _meta: ENVELOPE } }) + }); +} + +async function expectHeaderMismatch(response: Response): Promise<{ code: number; message: string }> { + expect(response.status).toBe(400); + const body = (await response.json()) as { id: unknown; error: { code: number; message: string } }; + expect(body.id).toBe(5); + expect(body.error.code).toBe(-32_020); + return body.error; +} + +describe('SEP-2243 standard-header validation (createMcpHandler, modern era)', () => { + it('a fully conformant tools/call passes and dispatches', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch( + modernRequest('tools/call', { name: 'echo', arguments: { text: 'hi' } }, { 'mcp-method': 'tools/call', 'mcp-name': 'echo' }) + ); + 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('a missing Mcp-Method header is rejected 400/-32020', async () => { + const handler = createMcpHandler(makeFactory()); + const error = await expectHeaderMismatch(await handler.fetch(modernRequest('tools/list', {}))); + expect(error.message).toContain('Mcp-Method header is absent'); + }); + + it('a missing Mcp-Name header on tools/call is rejected 400/-32020', async () => { + const handler = createMcpHandler(makeFactory()); + const error = await expectHeaderMismatch( + await handler.fetch(modernRequest('tools/call', { name: 'echo', arguments: {} }, { 'mcp-method': 'tools/call' })) + ); + expect(error.message).toContain('Mcp-Name header is absent'); + }); + + it('an Mcp-Name header disagreeing with params.name is rejected 400/-32020', async () => { + const handler = createMcpHandler(makeFactory()); + const error = await expectHeaderMismatch( + await handler.fetch( + modernRequest('tools/call', { name: 'echo', arguments: {} }, { 'mcp-method': 'tools/call', 'mcp-name': 'wrong' }) + ) + ); + expect(error.message).toContain('Mcp-Name header names "wrong"'); + }); + + it('Mcp-Name accepts an OWS-padded value (RFC 9110 §5.5; Fetch Headers normalises)', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch( + modernRequest('tools/call', { name: 'echo', arguments: {} }, { 'mcp-method': 'tools/call', 'mcp-name': ' echo ' }) + ); + expect(response.status).toBe(200); + }); + + it('Mcp-Name decodes a Base64 sentinel before comparison', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch( + modernRequest( + 'tools/call', + { name: 'echo', arguments: {} }, + { 'mcp-method': 'tools/call', 'mcp-name': encodeMcpParamValue('echo') } + ) + ); + // `encodeMcpParamValue('echo')` is plain ASCII, so the sentinel is not + // applied; assert the explicit-sentinel case below instead. + expect(response.status).toBe(200); + const sentinel = await handler.fetch( + modernRequest( + 'tools/call', + { name: 'echo', arguments: {} }, + { 'mcp-method': 'tools/call', 'mcp-name': `=?base64?${Buffer.from('echo').toString('base64')}?=` } + ) + ); + expect(sentinel.status).toBe(200); + }); + + it('an invalid Mcp-Name Base64 sentinel is rejected 400/-32020', async () => { + const handler = createMcpHandler(makeFactory()); + await expectHeaderMismatch( + await handler.fetch( + modernRequest( + 'tools/call', + { name: 'echo', arguments: {} }, + { 'mcp-method': 'tools/call', 'mcp-name': '=?base64?SGVsbG8?=' } + ) + ) + ); + }); + + it('Mcp-Name is not required for methods outside its source map', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch(modernRequest('tools/list', {}, { 'mcp-method': 'tools/list' })); + expect(response.status).toBe(200); + }); +}); + +describe('SEP-2243 standard-header validation is era-gated', () => { + it('legacy traffic is byte-untouched: a 2025-era initialize without standard headers still serves', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch( + 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: 5, + method: 'initialize', + params: { protocolVersion: '2025-11-25', clientInfo: { name: 'c', version: '1' }, capabilities: {} } + }) + }) + ); + // The default 'stateless' legacy posture answers initialize. + expect(response.status).toBe(200); + }); +}); diff --git a/packages/server/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts index 7a23dd56bb..b95e8bafd1 100644 --- a/packages/server/test/server/streamableHttp.test.ts +++ b/packages/server/test/server/streamableHttp.test.ts @@ -705,6 +705,369 @@ describe('Zod v4', () => { // Should have id: field in the SSE event expect(text).toContain('id:'); }); + + it('should store request-related events emitted after closeSSEStream() and not throw on the final response', async () => { + // The SEP-1699 poll-and-replay flow: handler closes the per-request + // SSE stream, then emits a notification and its final result while + // the client has not yet reconnected. Both must be persisted to the + // eventStore (so they replay on Last-Event-ID reconnect) and the + // final-response send must not surface a spurious error. + mcpServer.registerTool( + 'poll', + { description: 'closeSSE then emit', inputSchema: z.object({}) }, + async (_args, ctx): Promise => { + ctx.http?.closeSSE?.(); + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken: 'poll-1', progress: 75 } + }); + return { content: [{ type: 'text', text: 'done' }] }; + } + ); + + const sendErrors: unknown[] = []; + mcpServer.server.onerror = e => sendErrors.push(e); + + sessionId = await initializeServer(); + storedEvents.clear(); + + const callMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { name: 'poll', arguments: {} }, + id: 'poll-1' + }; + const response = await transport.handleRequest(createRequest('POST', callMessage, { sessionId })); + // closeSSE() in the handler closes the controller; drain the (now + // closed) body so the Response is fully consumed. + await response.text().catch(() => {}); + // Let the async handler chain (notify + final response send) settle. + await new Promise(resolve => setTimeout(resolve, 50)); + + const stored = [...storedEvents.values()].map(e => e.message); + expect( + stored.some(m => 'method' in m && m.method === 'notifications/progress'), + 'progress notification should be stored for replay after closeSSE()' + ).toBe(true); + expect( + stored.some(m => 'id' in m && m.id === 'poll-1' && 'result' in m), + 'final response should be stored for replay after closeSSE()' + ).toBe(true); + expect(sendErrors).toEqual([]); + }); + + it('should store request-related events after a client disconnect while the request is still in flight', async () => { + // Per 2025-11-25 transports.mdx, disconnection SHOULD NOT be + // interpreted as the client cancelling its request — storage is + // keyed on request-in-flight (_requestToStreamMapping), not on + // whether a live SSE writer exists. The final-response send must + // not throw and must clear the request id from the mapping. + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + mcpServer.registerTool( + 'disconnect', + { description: 'emit after client disconnect', inputSchema: z.object({}) }, + async (_args, ctx): Promise => { + await gate; + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken: 'disconnect-1', progress: 50 } + }); + return { content: [{ type: 'text', text: 'done' }] }; + } + ); + const sendErrors: unknown[] = []; + mcpServer.server.onerror = e => sendErrors.push(e); + + sessionId = await initializeServer(); + + const callMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { name: 'disconnect', arguments: {} }, + id: 'disconnect-1' + }; + const response = await transport.handleRequest(createRequest('POST', callMessage, { sessionId })); + storedEvents.clear(); + // Client disconnect: cancel the per-request stream (the ReadableStream + // cancel callback deletes the _streamMapping entry — no live writer). + await response.body?.cancel(); + await new Promise(resolve => setTimeout(resolve, 10)); + release(); + await new Promise(resolve => setTimeout(resolve, 50)); + + const stored = [...storedEvents.values()].map(e => e.message); + expect( + stored.some(m => 'method' in m && m.method === 'notifications/progress'), + 'progress notification should be stored while request is in flight (disconnect ≠ cancel)' + ).toBe(true); + expect( + stored.some(m => 'id' in m && m.id === 'disconnect-1' && 'result' in m), + 'final response should be stored for replay when no live writer exists' + ).toBe(true); + expect(sendErrors).toEqual([]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((transport as any)._requestToStreamMapping.has('disconnect-1')).toBe(false); + }); + + it('should accept Last-Event-ID reconnect after closeSSEStream() and replay stored events', async () => { + // closeSSEStream() removes the _streamMapping entry, so the + // replayEvents() conflict check sees no active connection and the + // reconnect succeeds (200), replaying the stored notification. + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + mcpServer.registerTool( + 'reconnect', + { description: 'closeSSE, emit, then wait for reconnect', inputSchema: z.object({}) }, + async (_args, ctx): Promise => { + ctx.http?.closeSSE?.(); + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken: 'reconnect-1', progress: 75 } + }); + await gate; + return { content: [{ type: 'text', text: 'done' }] }; + } + ); + mcpServer.server.onerror = () => {}; + + sessionId = await initializeServer(); + storedEvents.clear(); + + const callMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { name: 'reconnect', arguments: {} }, + id: 'reconnect-1' + }; + const response = await transport.handleRequest(createRequest('POST', callMessage, { sessionId })); + // Read the priming event so we have a Last-Event-ID to reconnect with. + const primingText = await response.text().catch(() => ''); + const primingId = /id:\s*(\S+)/.exec(primingText)?.[1]; + expect(primingId).toBeDefined(); + // Let the closeSSE + notify settle (handler is now gated on `gate`). + await new Promise(resolve => setTimeout(resolve, 30)); + + // Reconnect with Last-Event-ID while the request is still in flight. + const reconnect = await transport.handleRequest( + createRequest('GET', undefined, { sessionId, extraHeaders: { 'Last-Event-ID': primingId! } }) + ); + expect(reconnect.status).toBe(200); + release(); + const replayed = await readSSEEvent(reconnect); + expect(replayed).toContain('notifications/progress'); + }); + + it('should close and unregister the resumed stream when reconnecting after the request was already retired', async () => { + // Retire-then-reconnect: handler closeSSE → emit + result. With no + // live writer the final response is stored and the request id is + // retired by the clean-return path. A subsequent Last-Event-ID + // reconnect must replay the stored response AND close the resumed + // stream (per spec: server SHOULD close the SSE stream after the + // JSON-RPC response) so a second reconnect is not refused with 409. + mcpServer.registerTool( + 'retire', + { description: 'closeSSE then emit then return', inputSchema: z.object({}) }, + async (_args, ctx): Promise => { + ctx.http?.closeSSE?.(); + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken: 'retire-1', progress: 75 } + }); + return { content: [{ type: 'text', text: 'done' }] }; + } + ); + mcpServer.server.onerror = () => {}; + + sessionId = await initializeServer(); + storedEvents.clear(); + + const callMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { name: 'retire', arguments: {} }, + id: 'retire-1' + }; + const response = await transport.handleRequest(createRequest('POST', callMessage, { sessionId })); + // Read the priming event so we have a Last-Event-ID to reconnect with. + const primingText = await response.text().catch(() => ''); + const primingId = /id:\s*(\S+)/.exec(primingText)?.[1]; + expect(primingId).toBeDefined(); + // Let the handler chain (notify + final response send → clean-return) settle. + await new Promise(resolve => setTimeout(resolve, 50)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((transport as any)._requestToStreamMapping.has('retire-1')).toBe(false); + + // Reconnect after the request was retired. + const reconnect = await transport.handleRequest( + createRequest('GET', undefined, { sessionId, extraHeaders: { 'Last-Event-ID': primingId! } }) + ); + expect(reconnect.status).toBe(200); + const replayed = await reconnect.text(); + expect(replayed).toContain('notifications/progress'); + expect(replayed).toContain('"id":"retire-1"'); + expect(replayed, 'replay should include the stored final response').toContain('"result"'); + + // Resumed stream must have been closed and unregistered: a second + // reconnect with the same Last-Event-ID is accepted (200), not 409. + const reconnect2 = await transport.handleRequest( + createRequest('GET', undefined, { sessionId, extraHeaders: { 'Last-Event-ID': primingId! } }) + ); + expect(reconnect2.status).toBe(200); + await reconnect2.body?.cancel(); + }); + + it('should write to a stream resumed during the storeEvent() await (re-read after await, no TOCTOU)', async () => { + // send() reads _streamMapping[streamId] before `await storeEvent()` + // and decides on that snapshot. A Last-Event-ID reconnect during + // the await registers a resumed stream — send() must re-read after + // the await so the final response is written to it (and the + // all-responses-ready path closes/unregisters it), not silently + // dropped into the clean-return. + let handlerRelease!: () => void; + const handlerGate = new Promise(resolve => { + handlerRelease = resolve; + }); + mcpServer.registerTool( + 'toctou', + { description: 'closeSSE, gate, then return', inputSchema: z.object({}) }, + async (_args, ctx): Promise => { + ctx.http?.closeSSE?.(); + await handlerGate; + return { content: [{ type: 'text', text: 'done' }] }; + } + ); + mcpServer.server.onerror = () => {}; + + sessionId = await initializeServer(); + storedEvents.clear(); + + // Gate storeEvent() only after we flip `gateStores` (the priming + // event must store and flush ungated so we have a Last-Event-ID). + let gateStores = false; + let storeRelease!: () => void; + const storeGate = new Promise(resolve => { + storeRelease = resolve; + }); + const realStoreEvent = eventStore.storeEvent.bind(eventStore); + eventStore.storeEvent = async (sid, msg) => { + const id = await realStoreEvent(sid, msg); + if (gateStores) { + await storeGate; + } + return id; + }; + + const callMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { name: 'toctou', arguments: {} }, + id: 'toctou-1' + }; + const response = await transport.handleRequest(createRequest('POST', callMessage, { sessionId })); + const primingText = await response.text().catch(() => ''); + const primingId = /id:\s*(\S+)/.exec(primingText)?.[1]; + expect(primingId).toBeDefined(); + + // Arm the storeEvent gate, then let the handler return so send() + // for the final response enters `await storeEvent()` and parks. + gateStores = true; + handlerRelease(); + await new Promise(resolve => setTimeout(resolve, 30)); + + // Reconnect while storeEvent() is pending — registers a resumed + // stream under the same streamId. The request is still in flight + // (send() is parked on the await), so replayEvents() leaves it open. + const reconnect = await transport.handleRequest( + createRequest('GET', undefined, { sessionId, extraHeaders: { 'Last-Event-ID': primingId! } }) + ); + expect(reconnect.status).toBe(200); + + // Release storeEvent — send() re-reads _streamMapping, sees the + // resumed stream, writes the result, and the all-responses-ready + // path closes/unregisters it. + storeRelease(); + const body = await reconnect.text(); + expect(body, 'final response must be written to the resumed stream').toContain('"id":"toctou-1"'); + expect(body).toContain('"result"'); + // Exactly-once on the resumed stream: replay may have written it, + // and send() must dedup against replayedEventIds (no double write). + expect(body.match(/"id":"toctou-1"/g)?.length, 'result must be delivered exactly once').toBe(1); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((transport as any)._requestToStreamMapping.has('toctou-1')).toBe(false); + // Resumed stream was closed/unregistered: second reconnect → 200. + const reconnect2 = await transport.handleRequest( + createRequest('GET', undefined, { sessionId, extraHeaders: { 'Last-Event-ID': primingId! } }) + ); + expect(reconnect2.status).toBe(200); + await reconnect2.body?.cancel(); + }); + + it('should not let a stale per-request cancel delete a successor resumed stream (identity-guarded)', async () => { + // EventStore without getStreamIdForEventId → replayEvents() skips + // the conflict check and registers the resumed stream under the + // SAME streamId, OVERWRITING the original entry. A late cancel of + // the original POST body must identity-check before deleting so + // the successor survives and receives subsequent send()s. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (eventStore as any).getStreamIdForEventId; + + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + mcpServer.registerTool( + 'stalecancel', + { description: 'gate then return', inputSchema: z.object({}) }, + async (): Promise => { + await gate; + return { content: [{ type: 'text', text: 'done' }] }; + } + ); + mcpServer.server.onerror = () => {}; + + sessionId = await initializeServer(); + storedEvents.clear(); + + const callMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { name: 'stalecancel', arguments: {} }, + id: 'stalecancel-1' + }; + const original = await transport.handleRequest(createRequest('POST', callMessage, { sessionId })); + const reader = original.body!.getReader(); + const { value } = await reader.read(); + const primingText = new TextDecoder().decode(value); + const primingId = /id:\s*(\S+)/.exec(primingText)?.[1]; + expect(primingId).toBeDefined(); + + // Reconnect while the original is still mapped (no conflict check + // without getStreamIdForEventId) — successor overwrites the entry. + const reconnect = await transport.handleRequest( + createRequest('GET', undefined, { sessionId, extraHeaders: { 'Last-Event-ID': primingId! } }) + ); + expect(reconnect.status).toBe(200); + + // Late cancel of the ORIGINAL per-request stream — its source + // cancel callback fires now. Identity-guard must keep the + // successor's _streamMapping entry intact. + await reader.cancel(); + await new Promise(resolve => setTimeout(resolve, 10)); + + // Handler returns → send() finds the successor and writes to it. + release(); + const body = await reconnect.text(); + expect(body, 'result must reach the successor resumed stream').toContain('"id":"stalecancel-1"'); + expect(body).toContain('"result"'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((transport as any)._requestToStreamMapping.has('stalecancel-1')).toBe(false); + }); }); describe('HTTPServerTransport - Protocol Version Validation', () => { @@ -794,6 +1157,56 @@ describe('Zod v4', () => { return response.headers.get('mcp-session-id') as string; } + it('should call onerror when the per-request stream disconnects mid-handler with no eventStore configured', async () => { + // Sessionful transport WITHOUT an eventStore: a final response sent + // while no live writer exists is undeliverable AND not stored. The + // drop must be observable via onerror, and the request id retired. + // Fresh server (the suite beforeEach connects before any tool is + // registered, which would trip registerCapabilities). + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + const localServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + localServer.registerTool( + 'disconnect', + { description: 'return after client disconnect', inputSchema: z.object({}) }, + async (): Promise => { + await gate; + return { content: [{ type: 'text', text: 'done' }] }; + } + ); + const localTransport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); + const localErrors: Error[] = []; + localServer.server.onerror = e => localErrors.push(e as Error); + await localServer.connect(localTransport); + + const initRes = await localTransport.handleRequest(createRequest('POST', TEST_MESSAGES.initialize)); + const sessionId = initRes.headers.get('mcp-session-id') as string; + + const callMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { name: 'disconnect', arguments: {} }, + id: 'disconnect-1' + }; + const response = await localTransport.handleRequest(createRequest('POST', callMessage, { sessionId })); + // Client hard-disconnect: cancel the per-request stream (the + // ReadableStream cancel callback drops the _streamMapping entry). + await response.body?.cancel(); + await new Promise(resolve => setTimeout(resolve, 10)); + release(); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(localErrors.length, 'onerror should surface the undeliverable response').toBeGreaterThan(0); + expect(localErrors[0]?.message).toMatch(/undeliverable.*no eventStore is configured/); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((localTransport as any)._requestToStreamMapping.has('disconnect-1')).toBe(false); + await localTransport.close(); + }); + it('should call onerror for invalid JSON', async () => { const request = new Request('http://localhost/mcp', { method: 'POST', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ffd38d3dd..a07299bb56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -293,72 +293,534 @@ importers: specifier: catalog:devTools version: 5.1.4(typescript@5.9.3)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) - examples/client: + examples: dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) + '@mcp-examples/shared': + specifier: workspace:^ + version: link:shared + '@modelcontextprotocol/client': + specifier: workspace:^ + version: link:../packages/client + '@modelcontextprotocol/express': + specifier: workspace:^ + version: link:../packages/middleware/express + '@modelcontextprotocol/hono': + specifier: workspace:^ + version: link:../packages/middleware/hono + '@modelcontextprotocol/node': + specifier: workspace:^ + version: link:../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:^ + version: link:../packages/server + '@valibot/to-json-schema': + specifier: catalog:devTools + version: 1.6.0(valibot@1.3.1(typescript@5.9.3)) + ajv: + specifier: catalog:runtimeShared + version: 8.18.0 + arktype: + specifier: catalog:devTools + version: 2.2.0 + cors: + specifier: catalog:runtimeServerOnly + version: 2.8.6 + express: + specifier: catalog:runtimeServerOnly + version: 5.2.1 + hono: + specifier: catalog:runtimeServerOnly + version: 4.12.9 + open: + specifier: ^11.0.0 + version: 11.0.0 + valibot: + specifier: catalog:devTools + version: 1.3.1(typescript@5.9.3) + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../common/eslint-config + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../common/tsconfig + '@types/cors': + specifier: catalog:devTools + version: 2.8.19 + '@types/express': + specifier: catalog:devTools + version: 5.0.6 + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/bearer-auth: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/caching: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/client-quickstart: + dependencies: + '@anthropic-ai/sdk': + specifier: ^0.74.0 + version: 0.74.0(zod@4.3.6) + '@modelcontextprotocol/client': + specifier: workspace:^ + version: link:../../packages/client + devDependencies: + '@types/node': + specifier: ^24.10.1 + version: 24.12.0 + typescript: + specifier: catalog:devTools + version: 5.9.3 + + examples/custom-methods: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/custom-version: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/dual-era: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/elicitation: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/gateway: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/hono: + dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/hono': + specifier: workspace:* + version: link:../../packages/middleware/hono + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/json-response: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/legacy-routing: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + cors: + specifier: catalog:runtimeServerOnly + version: 2.8.6 + express: + specifier: catalog:runtimeServerOnly + version: 5.2.1 + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + '@types/cors': + specifier: catalog:devTools + version: 2.8.19 + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/mrtr: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/oauth: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + cors: + specifier: catalog:runtimeServerOnly + version: 2.8.6 + open: + specifier: ^11.0.0 + version: 11.0.0 + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + '@types/cors': + specifier: catalog:devTools + version: 2.8.19 + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/oauth-client-credentials: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/parallel-calls: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/prompts: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/repl: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared '@modelcontextprotocol/client': - specifier: workspace:^ + specifier: workspace:* version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server ajv: specifier: catalog:runtimeShared version: 8.18.0 - open: - specifier: ^11.0.0 - version: 11.0.0 + express: + specifier: catalog:runtimeServerOnly + version: 5.2.1 zod: specifier: catalog:runtimeShared version: 4.3.6 devDependencies: - '@modelcontextprotocol/eslint-config': - specifier: workspace:^ - version: link:../../common/eslint-config - '@modelcontextprotocol/examples-shared': - specifier: workspace:^ + '@types/express': + specifier: catalog:devTools + version: 5.0.6 + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/resources: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* version: link:../shared - '@modelcontextprotocol/tsconfig': - specifier: workspace:^ - version: link:../../common/tsconfig - '@modelcontextprotocol/vitest-config': - specifier: workspace:^ - version: link:../../common/vitest-config - tsdown: + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + devDependencies: + tsx: specifier: catalog:devTools - version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) + version: 4.21.0 - examples/client-quickstart: + examples/sampling: dependencies: - '@anthropic-ai/sdk': - specifier: ^0.74.0 - version: 0.74.0(zod@4.3.6) + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared '@modelcontextprotocol/client': - specifier: workspace:^ + specifier: workspace:* version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 devDependencies: - '@types/node': - specifier: ^24.10.1 - version: 24.12.0 - typescript: + tsx: specifier: catalog:devTools - version: 5.9.3 + version: 4.21.0 - examples/server: + examples/schema-validators: dependencies: - '@hono/node-server': - specifier: catalog:runtimeServerOnly - version: 1.19.11(hono@4.12.9) - '@modelcontextprotocol/examples-shared': - specifier: workspace:^ + '@mcp-examples/shared': + specifier: workspace:* version: link:../shared - '@modelcontextprotocol/express': - specifier: workspace:^ - version: link:../../packages/middleware/express - '@modelcontextprotocol/hono': - specifier: workspace:^ - version: link:../../packages/middleware/hono + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client '@modelcontextprotocol/node': - specifier: workspace:^ + specifier: workspace:* version: link:../../packages/middleware/node '@modelcontextprotocol/server': - specifier: workspace:^ + specifier: workspace:* version: link:../../packages/server '@valibot/to-json-schema': specifier: catalog:devTools @@ -366,18 +828,6 @@ importers: arktype: specifier: catalog:devTools version: 2.2.0 - better-auth: - specifier: ^1.4.17 - version: 1.5.6(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3))) - cors: - specifier: catalog:runtimeServerOnly - version: 2.8.6 - express: - specifier: catalog:runtimeServerOnly - version: 5.2.1 - hono: - specifier: catalog:runtimeServerOnly - version: 4.12.9 valibot: specifier: catalog:devTools version: 1.3.1(typescript@5.9.3) @@ -385,24 +835,37 @@ importers: specifier: catalog:runtimeShared version: 4.3.6 devDependencies: - '@modelcontextprotocol/eslint-config': - specifier: workspace:^ - version: link:../../common/eslint-config - '@modelcontextprotocol/tsconfig': - specifier: workspace:^ - version: link:../../common/tsconfig - '@modelcontextprotocol/vitest-config': - specifier: workspace:^ - version: link:../../common/vitest-config - '@types/cors': - specifier: catalog:devTools - version: 2.8.19 - '@types/express': + tsx: specifier: catalog:devTools - version: 5.0.6 - tsdown: + version: 4.21.0 + + examples/scoped-tools: + dependencies: + '@mcp-examples/oauth': + specifier: workspace:* + version: link:../oauth + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: specifier: catalog:devTools - version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) + version: 4.21.0 examples/server-quickstart: dependencies: @@ -496,6 +959,172 @@ importers: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + examples/sse-polling: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + cors: + specifier: catalog:runtimeServerOnly + version: 2.8.6 + express: + specifier: catalog:runtimeServerOnly + version: 5.2.1 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/standalone-get: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/express': + specifier: workspace:* + version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + express: + specifier: catalog:runtimeServerOnly + version: 5.2.1 + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/stateless-legacy: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/stickynotes: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/streaming: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/subscriptions: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + + examples/tools: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + packages/client: dependencies: cross-spawn: @@ -1099,8 +1728,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.7 + version: 0.2.0-alpha.7(@cfworker/json-schema@4.1.1) '@modelcontextprotocol/core': specifier: workspace:^ version: link:../../packages/core @@ -2111,8 +2740,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.7': + resolution: {integrity: sha512-S3usVyTWdEqvJpyGnXAz6Uj4yboBwT44lrmMqaQtxKcw4PK8H5XfdH5NQqNmDl9/zQbk4oDxtXqzUyGqTbEDzg==} hasBin: true '@modelcontextprotocol/sdk@1.29.0': @@ -6001,7 +6630,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.7(@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/pnpm-workspace.yaml b/pnpm-workspace.yaml index e15c6b22b8..3923d58ef0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,8 @@ packages: - packages/**/* - '!packages/codemod/batch-test/**' - common/**/* + - examples + - examples/* - examples/**/* - test/**/* diff --git a/scripts/examples/run-examples.ts b/scripts/examples/run-examples.ts new file mode 100644 index 0000000000..db3f8fd546 --- /dev/null +++ b/scripts/examples/run-examples.ts @@ -0,0 +1,232 @@ +#!/usr/bin/env tsx +/** + * Build-and-e2e-run every story under `examples/` over every transport × era + * leg it supports. Each story's `client.ts` is a self-verifying top-level-await + * script: a `check.*` failure throws, Node prints the error and exits 1; on + * success `client.close()` releases the last handle and Node exits 0. The + * harness reports PASS/FAIL from the child's exit code (a timeout is a FAIL + * with "hung — possible unclosed handle"). + * + * - **stdio** (default for dual-transport stories): run `client.ts` with no + * transport flag; it spawns the sibling server binary itself and speaks + * MCP over the pipe. + * - **HTTP**: start `server.ts --http --port

`, poll until ready, run + * `client.ts --http http://127.0.0.1:

/`, kill the server. + * - **modern** (default): the client negotiates the 2026-07-28 era + * (`versionNegotiation: { mode: 'auto' }`). + * - **legacy**: pass `--legacy` to the client so it uses the 2025 + * `initialize` handshake (`versionNegotiation: { mode: 'legacy' }`). + * + * Per-story configuration lives in the story's `package.json` under the + * `"example"` field — most stories have none. `excluded` stories are listed + * (with their reason) but not run. Stories without a `client.ts` are skipped. + */ +import { spawn, type ChildProcess } from 'node:child_process'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { connect } from 'node:net'; +import { join, resolve } from 'node:path'; + +type Era = 'modern' | 'legacy'; +type Transport = 'stdio' | 'http'; + +interface ExampleConfig { + /** Transports to run (default: `['stdio', 'http']`). */ + transports?: Transport[]; + /** `'dual'` (modern + legacy; the default), `'modern'`, or `'legacy'`. */ + era?: 'dual' | Era; + /** HTTP port (default: a per-story port assigned below). */ + port?: number; + /** Endpoint path (default: `'/mcp'`). */ + path?: string; + /** Extra environment for the server process. */ + env?: Record; + /** Per-leg timeout in milliseconds (default: 30000). */ + timeoutMs?: number; + /** Optional substring the client's stdout must contain. */ + expects?: { stdout?: string }; + /** When present, the story is skipped (with this reason printed). */ + excluded?: string; +} + +const ROOT = resolve(import.meta.dirname, '../..'); +const EXAMPLES = join(ROOT, 'examples'); +const TSX = join(ROOT, 'node_modules', '.bin', 'tsx'); + +/** Directories that are never stories. */ +const NON_STORY = new Set(['shared', 'guides', 'server-quickstart', 'client-quickstart', 'node_modules']); + +/** Distinct per-story HTTP ports so the servers never collide. */ +let nextPort = 8530; +const portFor = new Map(); +function assignPort(story: string, config: ExampleConfig): number { + if (config.port) return config.port; + if (!portFor.has(story)) portFor.set(story, nextPort++); + return portFor.get(story)!; +} + +function readConfig(dir: string): ExampleConfig { + const file = join(dir, 'package.json'); + if (!existsSync(file)) return {}; + const pkg = JSON.parse(readFileSync(file, 'utf8')) as { example?: ExampleConfig }; + return pkg.example ?? {}; +} + +function run( + cmd: string, + args: string[], + opts: { cwd: string; env?: Record; timeoutMs: number } +): Promise<{ code: number; stdout: string; stderr: string; timedOut: boolean }> { + return new Promise(resolvePromise => { + const child = spawn(cmd, args, { cwd: opts.cwd, env: { ...process.env, ...opts.env } }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', d => (stdout += String(d))); + child.stderr.on('data', d => (stderr += String(d))); + const timer = setTimeout(() => { + child.kill('SIGKILL'); + resolvePromise({ code: 124, stdout, stderr: stderr + '\n[harness] timed out', timedOut: true }); + }, opts.timeoutMs); + child.on('close', code => { + clearTimeout(timer); + resolvePromise({ code: code ?? 1, stdout, stderr, timedOut: false }); + }); + child.on('error', err => { + clearTimeout(timer); + resolvePromise({ code: 1, stdout, stderr: stderr + `\n[harness] spawn error: ${err.message}`, timedOut: false }); + }); + }); +} + +/** + * Story `client.ts` files are top-level-await scripts: a thrown `check.*` + * propagates as an unhandled rejection (Node prints + exits 1); a clean run + * exits 0 once `client.close()` releases the last handle. The harness prints + * PASS/FAIL itself from the child's exit code — there is no in-band OK/FAIL + * line. A timeout means the process never exited on its own and is reported as + * a hang (possible unclosed handle). + */ +function toLegResult( + story: string, + leg: string, + result: { code: number; stdout: string; stderr: string; timedOut: boolean }, + config: ExampleConfig, + serverLog?: string +): LegResult { + if (result.timedOut) { + return { story, leg, ok: false, detail: `(hung — possible unclosed handle)\n${result.stderr || result.stdout}${serverLog ?? ''}` }; + } + const ok = result.code === 0 && (!config.expects?.stdout || result.stdout.includes(config.expects.stdout)); + return { + story, + leg, + ok, + detail: ok + ? (result.stdout.trim().split('\n').pop() ?? '') + : `exit ${result.code}\n${result.stderr || result.stdout}${serverLog ?? ''}` + }; +} + +async function waitForPort(port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const ok = await new Promise(resolvePromise => { + const sock = connect({ port, host: '127.0.0.1' }, () => { + sock.destroy(); + resolvePromise(true); + }); + sock.on('error', () => resolvePromise(false)); + }); + if (ok) return true; + await new Promise(r => setTimeout(r, 200)); + } + return false; +} + +interface LegResult { + story: string; + leg: string; + ok: boolean; + detail: string; +} + +const eraArgs = (era: Era): string[] => (era === 'legacy' ? ['--legacy'] : []); + +async function runStdioLeg(story: string, dir: string, config: ExampleConfig, era: Era): Promise { + const timeoutMs = config.timeoutMs ?? 30_000; + const result = await run(TSX, [join(dir, 'client.ts'), ...eraArgs(era)], { cwd: ROOT, timeoutMs }); + return toLegResult(story, `stdio/${era}`, result, config); +} + +async function runHttpLeg(story: string, dir: string, config: ExampleConfig, era: Era): Promise { + const timeoutMs = config.timeoutMs ?? 30_000; + const port = assignPort(story, config); + const path = config.path ?? '/mcp'; + const url = `http://127.0.0.1:${port}${path}`; + let serverStderr = ''; + const server: ChildProcess = spawn(TSX, [join(dir, 'server.ts'), '--http', '--port', String(port)], { + cwd: ROOT, + env: { ...process.env, PORT: String(port), ...config.env } + }); + server.stderr?.on('data', d => (serverStderr += String(d))); + server.stdout?.on('data', d => (serverStderr += String(d))); + try { + const ready = await waitForPort(port, 15_000); + if (!ready) { + return { story, leg: `http/${era}`, ok: false, detail: `server never bound :${port}\n--- server log ---\n${serverStderr}` }; + } + const result = await run(TSX, [join(dir, 'client.ts'), '--http', url, ...eraArgs(era)], { cwd: ROOT, timeoutMs }); + return toLegResult(story, `http/${era}`, result, config, `\n--- server log ---\n${serverStderr}`); + } finally { + server.kill('SIGTERM'); + await new Promise(r => setTimeout(r, 100)); + // `.killed` flips true the moment kill() is called, so it can't gate + // the backstop; check whether the process actually exited instead. + if (server.exitCode === null && server.signalCode === null) server.kill('SIGKILL'); + } +} + +async function main(): Promise { + const stories = readdirSync(EXAMPLES, { withFileTypes: true }) + .filter(d => d.isDirectory() && !NON_STORY.has(d.name)) + .map(d => d.name) + .filter(name => existsSync(join(EXAMPLES, name, 'client.ts'))) + .sort(); + + const results: LegResult[] = []; + const excluded: Array<{ story: string; reason: string }> = []; + + for (const story of stories) { + const dir = join(EXAMPLES, story); + const config = readConfig(dir); + if (config.excluded) { + excluded.push({ story, reason: config.excluded }); + console.log(`\n::group::example ${story}\nSKIPPED: ${config.excluded}\n::endgroup::`); + continue; + } + const transports: Transport[] = config.transports ?? ['stdio', 'http']; + const era = config.era ?? 'dual'; + const eras: Era[] = era === 'dual' ? ['modern', 'legacy'] : [era]; + console.log(`\n::group::example ${story} (${transports.join('+')} × ${era})`); + for (const t of transports) { + for (const e of eras) { + const r = t === 'stdio' ? await runStdioLeg(story, dir, config, e) : await runHttpLeg(story, dir, config, e); + results.push(r); + console.log(`[${r.leg}] ${r.ok ? 'PASS' : 'FAIL'}: ${r.detail.split('\n')[0]}`); + if (!r.ok) console.log(r.detail); + } + } + console.log('::endgroup::'); + } + + const passed = results.filter(r => r.ok).length; + const failed = results.filter(r => !r.ok); + console.log('\n=== examples e2e summary ==='); + console.log(`stories: ${stories.length - excluded.length} run / ${excluded.length} excluded`); + console.log(`legs: ${passed} passed / ${failed.length} failed`); + for (const r of failed) console.log(` FAIL ${r.story} [${r.leg}]`); + for (const e of excluded) console.log(` SKIP ${e.story}: ${e.reason}`); + + process.exit(failed.length === 0 ? 0 : 1); +} + +void main(); 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/eslint.config.mjs b/test/conformance/eslint.config.mjs index 951c9f3a91..9f247cf600 100644 --- a/test/conformance/eslint.config.mjs +++ b/test/conformance/eslint.config.mjs @@ -2,4 +2,32 @@ import baseConfig from '@modelcontextprotocol/eslint-config'; -export default baseConfig; +export default [ + ...baseConfig, + { + files: ['**/*.{ts,tsx,js,jsx,mts,cts}'], + rules: { + // Conformance fixtures MUST use only what a consumer would `npm install` and import: + // public package entry points. Anything reaching into core or package internals is banned. + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@modelcontextprotocol/core', '@modelcontextprotocol/core/*'], + message: 'Conformance fixtures must import from @modelcontextprotocol/{server,client}, not core.' + }, + { + group: ['@modelcontextprotocol/*/src/*'], + message: 'Conformance fixtures must import only public package entry points.' + }, + { + group: ['@modelcontextprotocol/*/dist/*'], + message: 'Conformance fixtures must import only public package entry points.' + } + ] + } + ] + } + } +]; 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..eebd8e1721 --- /dev/null +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -0,0 +1,34 @@ +# 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. +# +# NOTE: the SDK's modern-path rejection codes are aligned with what this +# referee asserts — both sides have adopted the spec#2907 / conformance#353 +# renumber (-32020 / -32021 / -32022) on emission and recognition. +# +# 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: [] + # --- Same gaps as the 2025 baseline (fail identically when forced to 2026-07-28) --- + # (empty: SEP-2468/2352/2350/837 burned by the auth bundle; SEP-2106 burned earlier) + +server: [] + # --- Carried-forward scenarios (also run by the 2025 legs) --- + # (empty: json-schema-2020-12 burned by SEP-2106 fixture; sep-2164-resource-not-found + # burned by the spec#2907 error-code renumber + alpha.5 referee.) diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index abfb3751d3..fe1e950a5d 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -2,92 +2,40 @@ # 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.7). 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 — both sides have adopted the spec#2907 / conformance#353 +# renumber (-32020 HeaderMismatch / -32021 MissingRequiredClientCapability / +# -32022 UnsupportedProtocolVersion) on emission and recognition. A missing +# _meta envelope (or missing protocolVersion key) still answers -32602 on +# both sides. # # 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 # runner fails on stale entries), so the baseline burns down per milestone. -client: +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 - # SEP-2243 (HTTP standardization): no fixture handler / client header support yet. - - http-custom-headers - - http-invalid-tool-headers - # SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs. - - 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 - # SEP-837 (application_type during DCR): the check only fires on draft-version - # runs; this draft scenario is the one place the client still hits it. - - auth/offline-access-not-supported - - # --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 --- - # SEP-2350 (scope step-up): WARNING-only — client does not compute the union of - # previously requested and newly challenged scopes on re-authorization; the - # expected-failures evaluator counts WARNINGs as failures. - - auth/scope-step-up - # SEP-990 (enterprise-managed authorization extension): no fixture handler / - # client support for the token-exchange + JWT bearer flow. - - auth/enterprise-managed-authorization + # (empty: SEP-2468/2352/2350/837/2207/990 burned by the auth bundle; the + # last referee-side gap — conformance#361 callback-iss — closed at alpha.6) 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. - - 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-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.) - - 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. - - sep-2164-resource-not-found - # SEP-2322 SHOULD-level behaviours (re-request missing inputResponses, ignore - # unrecognized inputResponses keys). - - input-required-result-missing-input-response - - input-required-result-ignore-extra-params + # --- SEP-2663 (io.modelcontextprotocol/tasks) — server SDK does not implement the tasks extension --- + # Extension-tagged scenarios; selected only by `--suite all` (the alpha.7 referee + # has no server-side `--suite extensions`). The active/draft/2026 legs never select + # them, so they cannot flag these entries as stale. `tasks-status-notifications` is + # intentionally absent: the referee SKIPs it unconditionally (harness rewrite pending + # against the SEP-2575 subscriptions/listen channel), so a baseline entry would be + # flagged stale. + - tasks-lifecycle + - tasks-capability-negotiation + - tasks-wire-fields + - tasks-request-state-removal + - tasks-mrtr-input + - tasks-request-headers + - tasks-dispatch-and-envelope + - tasks-required-task-error + - tasks-mrtr-composition diff --git a/test/conformance/package.json b/test/conformance/package.json index 7a1154b8ed..fa09cc7d33 100644 --- a/test/conformance/package.json +++ b/test/conformance/package.json @@ -30,15 +30,18 @@ "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:extensions": "scripts/run-server-conformance.sh --suite all --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.7", "@modelcontextprotocol/client": "workspace:^", "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/core": "workspace:^", diff --git a/test/conformance/src/authTestServer.ts b/test/conformance/src/authTestServer.ts index 5fbd5785d6..25fc790481 100644 --- a/test/conformance/src/authTestServer.ts +++ b/test/conformance/src/authTestServer.ts @@ -51,17 +51,10 @@ const ADMIN_SCOPE = 'admin'; // Function to create a new MCP server instance (one per session) function createMcpServer(): McpServer { - const mcpServer = new McpServer( - { - name: 'mcp-auth-test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - } - ); + const mcpServer = new McpServer({ + name: 'mcp-auth-test-server', + version: '1.0.0' + }); // Simple echo tool for testing authenticated calls mcpServer.registerTool( @@ -72,8 +65,7 @@ function createMcpServer(): McpServer { message: z.string().optional().describe('The message to echo back') }) }, - async (args: { message?: string }) => { - const message = args.message || 'No message provided'; + async ({ message = 'No message provided' }) => { return { content: [{ type: 'text', text: `Echo: ${message}` }] }; @@ -102,8 +94,7 @@ function createMcpServer(): McpServer { action: z.string().optional().describe('The admin action to perform') }) }, - async (args: { action?: string }) => { - const action = args.action || 'default-admin-action'; + async ({ action = 'default-admin-action' }) => { return { content: [{ type: 'text', text: `Admin action performed: ${action}` }] }; @@ -274,8 +265,8 @@ async function startServer() { app.use( cors({ origin: '*', - exposedHeaders: ['Mcp-Session-Id'], - allowedHeaders: ['Content-Type', 'mcp-session-id', 'last-event-id', 'Authorization'] + exposedHeaders: ['Mcp-Session-Id', 'WWW-Authenticate'], + allowedHeaders: ['Content-Type', 'mcp-session-id', 'last-event-id', 'Authorization', 'mcp-protocol-version'] }) ); diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index 05103eb26d..78a1bded3b 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -64,6 +64,15 @@ const ClientConformanceContextSchema = z.discriminatedUnion('name', [ idp_id_token: z.string(), idp_issuer: z.string(), idp_token_endpoint: z.string() + }), + z.object({ + name: z.literal('auth/enterprise-managed-authorization'), + client_id: z.string(), + client_secret: z.string(), + idp_client_id: z.string(), + idp_id_token: z.string(), + idp_issuer: z.string(), + idp_token_endpoint: z.string() }) ]); @@ -96,6 +105,25 @@ 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); +} + // ============================================================================ // Basic scenarios (initialize, tools_call) // ============================================================================ @@ -117,6 +145,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)); @@ -128,8 +160,7 @@ async function runToolsCallClient(serverUrl: string): Promise { logger.debug('Successfully listed tools'); // Call the add_numbers tool - const addTool = tools.tools.find(t => t.name === 'add_numbers'); - if (addTool) { + if (tools.tools.some(t => t.name === 'add_numbers')) { const result = await client.callTool({ name: 'add_numbers', arguments: { a: 5, b: 3 } @@ -141,15 +172,274 @@ 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 — the +// client attaches the per-request _meta envelope to every request itself. +async function runToolsCallModernClient(serverUrl: string): Promise { + const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {}, versionNegotiation: { mode: 'auto' } }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Negotiated protocol version:', client.getNegotiatedProtocolVersion()); + + const tools = await client.listTools(); + logger.debug('Successfully listed tools'); + + // Call the add_numbers tool + if (tools.tools.some(t => t.name === 'add_numbers')) { + const result = await client.callTool({ + name: 'add_numbers', + arguments: { a: 5, b: 3 } + }); + 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 -32022. 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); + +// ============================================================================ +// SEP-2243 standard-header client scenario (Mcp-Method / Mcp-Name) +// ============================================================================ + +// http-standard-headers: the referee mock answers initialize, tools/list, +// tools/call, resources/list, resources/read, prompts/list, prompts/get and +// asserts that each POST carried the correct Mcp-Method header (and Mcp-Name +// for the call/read/get verbs). The SDK emits both headers on the modern +// streamableHttp path, so the fixture just needs to drive each method once. +// The mock has no server/discover handler and its 2025-shaped initialize +// response doesn't satisfy the v2 client — same connect-time gap as the other +// SEP-2243 mocks — so connect via the withLocalDiscoverResponse shim. The +// initialize / notifications/initialized checks are intentionally left +// SKIPPED; the legacy initialize path's missing Mcp-Method is tracked as a +// baseline bug. The mock advertises its own surface (test_headers / +// file:///path/to/file%20name.txt / test_prompt) — the fixture lists first +// and uses whatever the mock returned so it stays referee-version-agnostic. +async function runHttpStandardHeadersClient(serverUrl: string): Promise { + const client = await connectModernHeaderClient(serverUrl); + logger.debug('Successfully connected to MCP server'); + + const { tools } = await client.listTools(); + const tool = tools[0]; + if (tool) { + await client.callTool({ name: tool.name, arguments: {} }); + } + + const { resources } = await client.listResources(); + const resource = resources[0]; + if (resource) { + await client.readResource({ uri: resource.uri }); + } + + const { prompts } = await client.listPrompts(); + const prompt = prompts[0]; + if (prompt) { + await client.getPrompt({ name: prompt.name, arguments: {} }); + } + + await client.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('http-standard-headers', runHttpStandardHeadersClient); + +// ============================================================================ +// SEP-2243 custom-header client scenarios (protocol revision 2026-07-28) +// ============================================================================ + +// The SEP-2243 conformance mocks (http-custom-headers / http-invalid-tool-headers) +// only implement tools/list + tools/call (and a 2025-shaped initialize pinned +// to 2026-07-28, no server/discover) — same connect-time gap as the +// multi-round-trip mock, so use the same withLocalDiscoverResponse fetch shim +// (defined below) to establish the modern era. The runner passes the exact +// tool calls to make via MCP_CONFORMANCE_CONTEXT. + +function readToolCallsContext(): Array<{ name: string; arguments: Record }> { + const raw = process.env.MCP_CONFORMANCE_CONTEXT; + if (!raw) return []; + const parsed = JSON.parse(raw) as { toolCalls?: Array<{ name: string; arguments: Record }> }; + return parsed.toolCalls ?? []; +} + +async function connectModernHeaderClient(serverUrl: string): Promise { + const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {}, versionNegotiation: { mode: 'auto' } }); + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: withLocalDiscoverResponse({ name: 'test-client', version: '1.0.0' }) + }); + await client.connect(transport); + return client; +} + +// http-custom-headers: the conformance mock advertises test_custom_headers and +// test_custom_headers_null with x-mcp-header annotations. List first (so the +// SDK caches the inputSchema and can mirror), then make the runner-supplied +// calls; the conformance mock validates the Mcp-Param-* headers it receives. +async function runHttpCustomHeadersClient(serverUrl: string): Promise { + const client = await connectModernHeaderClient(serverUrl); + const { tools } = await client.listTools(); + logger.debug('listed tools:', tools.map(t => t.name).join(', ')); + + for (const call of readToolCallsContext()) { + await client.callTool({ name: call.name, arguments: call.arguments }); + } + await client.close(); +} + +// http-invalid-tool-headers: the conformance mock advertises one valid tool +// alongside several constraint-violating ones. listTools() must exclude the +// invalid ones; the fixture then calls every tool that survived — a correct +// SDK leaves only valid_tool, so the mock records SUCCESS for the keep-valid +// check and SUCCESS for every excluded tool not having been called. +async function runHttpInvalidToolHeadersClient(serverUrl: string): Promise { + const client = await connectModernHeaderClient(serverUrl); + const { tools } = await client.listTools(); + logger.debug('post-exclusion tools:', tools.map(t => t.name).join(', ')); + + for (const tool of tools) { + await client.callTool({ name: tool.name, arguments: { region: 'us-west1' } }).catch(error => { + logger.debug(`call ${tool.name} rejected:`, String(error)); + }); + } + await client.close(); +} + +registerScenario('http-custom-headers', runHttpCustomHeadersClient); +registerScenario('http-invalid-tool-headers', runHttpInvalidToolHeadersClient); + +// ============================================================================ +// Multi-round-trip client scenario (SEP-2322, protocol revision 2026-07-28) +// ============================================================================ + +/** + * The multi-round-trip client scenario's mock server only implements + * `tools/list`, `tools/call` and `notifications/initialized`; it answers both + * `server/discover` and `initialize` with -32601, so neither connect-time + * negotiation path can establish the 2026-07-28 era against it. The scenario + * is pinned to 2026-07-28 (the runner resolves it there even on the + * default-version leg), so the fixture answers the connect-time + * `server/discover` probe locally through the transport's custom fetch and + * lets every other request reach the real mock. Everything the scenario + * measures — auto-fulfilment of the embedded elicitation, the byte-exact + * requestState echo, fresh JSON-RPC ids on retries, isolation of unrelated + * calls, and not retrying complete results — is the SDK driver's behavior + * against the real mock. + */ +function withLocalDiscoverResponse(serverInfo: { name: string; version: string }): typeof fetch { + return async (input, init) => { + if (typeof init?.body === 'string') { + try { + const message = JSON.parse(init.body) as { method?: string; id?: unknown }; + if (message.method === 'server/discover') { + return Response.json( + { + jsonrpc: '2.0', + id: message.id, + result: { + supportedVersions: ['2026-07-28'], + // Advertise the full read surface so capability-gated + // list/read/get calls reach the real mock; callers that + // only use tools are unaffected by the extra entries. + capabilities: { tools: { listChanged: true }, resources: {}, prompts: {} }, + serverInfo + } + }, + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } + } catch { + // Not a JSON-RPC body — fall through to the real fetch. + } + } + return fetch(input, init); + }; +} + +async function runMrtrClient(serverUrl: string): Promise { + const clientInfo = { name: 'test-client', version: '1.0.0' }; + const capabilities = { elicitation: {} }; + const client = new Client(clientInfo, { + capabilities, + versionNegotiation: { mode: 'auto' } + }); + + // The auto-fulfilment driver dispatches the embedded elicitation requests + // to this handler, exactly like a server-initiated elicitation. + client.setRequestHandler('elicitation/create', async request => { + logger.debug('Fulfilling embedded elicitation request:', JSON.stringify(request.params)); + return { action: 'accept' as const, content: { confirmed: true } }; + }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: withLocalDiscoverResponse(clientInfo) + }); + + await client.connect(transport); + logger.debug('Negotiated protocol version:', client.getNegotiatedProtocolVersion()); + + // requestState echo flow: the driver must echo the opaque state byte-exact + // and retry on a fresh JSON-RPC id. + const echoResult = await client.callTool({ name: 'test_mrtr_echo_state', arguments: {} }); + logger.debug('test_mrtr_echo_state result:', JSON.stringify(echoResult)); + + // No-state flow: the InputRequiredResult carries no requestState, so the + // retry must not include one. + const noStateResult = await client.callTool({ name: 'test_mrtr_no_state', arguments: {} }); + logger.debug('test_mrtr_no_state result:', JSON.stringify(noStateResult)); + + // Unrelated call: must not carry inputResponses or requestState from the + // multi-round-trip flows above. + const unrelatedResult = await client.callTool({ name: 'test_mrtr_unrelated', arguments: {} }); + logger.debug('test_mrtr_unrelated result:', JSON.stringify(unrelatedResult)); + + // Result without resultType: the check passes as long as the client does + // not retry with inputResponses. The SDK treats a missing resultType from + // a 2026-negotiated server as a protocol violation and rejects locally + // without retrying, so this call is expected to throw. + try { + const noResultTypeResult = await client.callTool({ name: 'test_mrtr_no_result_type', arguments: {} }); + logger.debug('test_mrtr_no_result_type result:', JSON.stringify(noResultTypeResult)); + } catch (error) { + logger.debug('test_mrtr_no_result_type rejected locally (no retry):', error instanceof Error ? error.message : String(error)); + } + + await client.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('sep-2322-client-request-state', runMrtrClient); // ============================================================================ // Auth scenarios - well-behaved client // ============================================================================ async function runAuthClient(serverUrl: string): Promise { - const client = new Client({ name: 'test-auth-client', version: '1.0.0' }, { capabilities: {} }); + const client = new Client({ name: 'test-auth-client', version: '1.0.0' }, { capabilities: {}, versionNegotiation: { mode: 'auto' } }); const oauthFetch = withOAuthRetry('test-auth-client', new URL(serverUrl), handle401, CIMD_CLIENT_METADATA_URL)(fetch); @@ -181,6 +471,10 @@ registerScenarios( 'auth/metadata-var3', 'auth/2025-03-26-oauth-metadata-backcompat', 'auth/2025-03-26-oauth-endpoint-fallback', + // RFC 8707 resource-indicator binding: the referee serves a PRM whose + // `resource` does not match the MCP server URL; the SDK's discovery path + // must reject before token exchange (the referee sets `allowClientError`). + 'auth/resource-mismatch', 'auth/scope-from-www-authenticate', 'auth/scope-from-scopes-supported', 'auth/scope-omitted-when-undefined', @@ -190,7 +484,23 @@ registerScenarios( 'auth/token-endpoint-auth-post', 'auth/token-endpoint-auth-none', 'auth/offline-access-scope', - 'auth/offline-access-not-supported' + 'auth/offline-access-not-supported', + // SEP-2468 (RFC 9207 iss / RFC 8414 §3.3 issuer-echo). The well-behaved + // client captures `iss` from the authorization redirect and passes it to + // `auth()`; the SDK validates internally. Positive scenarios proceed to + // the token endpoint; negative scenarios throw `IssuerMismatchError` and + // the process exits with an error (the referee sets `allowClientError`). + '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: PRM `authorization_servers` switches between calls; the client's + // issuer-stamped credential storage reads back as undefined at the new AS and + // re-registers there. + 'auth/authorization-server-migration' ], runAuthClient ); @@ -271,10 +581,16 @@ registerScenario('auth/client-credentials-basic', runClientCredentialsBasic); * then exchanges the ID-JAG for an access token at the AS (RFC 7523 JWT bearer grant * with client_secret_basic). The provider drives discovery + the JWT bearer step; the * assertion callback handles the IdP exchange using the context-supplied ID token. + * + * The two scenarios share the same context shape and the same client behavior: + * `auth/cross-app-access-complete-flow` is the single-AS variant; + * `auth/enterprise-managed-authorization` is the SEP-990 extension scenario that + * additionally validates `requested_token_type=id-jag`, ID-JAG `typ` and + * `client_id`/`resource` claim binding at the AS. */ async function runCrossAppAccessCompleteFlow(serverUrl: string): Promise { const ctx = parseContext(); - if (ctx.name !== 'auth/cross-app-access-complete-flow') { + if (ctx.name !== 'auth/cross-app-access-complete-flow' && ctx.name !== 'auth/enterprise-managed-authorization') { throw new Error(`Expected cross-app-access context, got ${ctx.name}`); } @@ -311,6 +627,7 @@ async function runCrossAppAccessCompleteFlow(serverUrl: string): Promise { } registerScenario('auth/cross-app-access-complete-flow', runCrossAppAccessCompleteFlow); +registerScenario('auth/enterprise-managed-authorization', runCrossAppAccessCompleteFlow); // ============================================================================ // Pre-registration scenario (no dynamic client registration) @@ -451,6 +768,40 @@ async function runSSERetryClient(serverUrl: string): Promise { registerScenario('sse-retry', runSSERetryClient); +// ============================================================================ +// JSON Schema $ref dereference scenario (SEP-2106) +// ============================================================================ + +/** + * The scenario serves a tool whose outputSchema carries a network `$ref`; the + * conformance check passes when the client lists tools without dereferencing + * (fetching) that URL. The SDK never dereferences network refs — output + * schemas are compiled lazily on the first `callTool()` against the cached + * `tools/list` entry, and the underlying engine (Ajv / cfworker) does not + * fetch external refs (Ajv throws `MissingRefError`, captured per-tool) — so + * a plain connect → listTools → close is sufficient: `listTools()` returns + * normally and the canary URL is never fetched. + */ +async function runJsonSchemaRefNoDerefClient(serverUrl: string): Promise { + const client = new Client({ name: 'json-schema-ref-no-deref-client', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + const tools = await client.listTools(); + logger.debug( + 'Available tools:', + tools.tools.map(t => t.name) + ); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('json-schema-ref-no-deref', runJsonSchemaRefNoDerefClient); + // ============================================================================ // Main entry point // ============================================================================ @@ -488,9 +839,4 @@ async function main(): Promise { } } -try { - await main(); -} catch (error) { - logger.error('Error:', error); - process.exit(1); -} +await main(); diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index 387054f0b1..0df9f18a0a 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -10,9 +10,32 @@ 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 { NodeStreamableHTTPServerTransport, toNodeHandler } from '@modelcontextprotocol/node'; +import type { + CallToolResult, + EventId, + EventStore, + GetPromptResult, + InputRequests, + InputRequiredResult, + ReadResourceResult, + ServerContext, + StreamId +} from '@modelcontextprotocol/server'; +import { + acceptedContent, + classifyInboundRequest, + CLIENT_CAPABILITIES_META_KEY, + createMcpHandler, + createRequestStateCodec, + fromJsonSchema, + inputRequired, + isInitializeRequest, + McpServer, + ProtocolError, + ProtocolErrorCode, + ResourceTemplate +} from '@modelcontextprotocol/server'; import cors from 'cors'; import type { Request, Response } from 'express'; import express from 'express'; @@ -32,7 +55,9 @@ const eventStoreData = new Map { - const eventId = `${streamId}::${Date.now()}_${randomUUID()}`; + // Fixed-width timestamp so the lexicographic sort in + // replayEventsAfter is robustly chronological. + const eventId = `${streamId}::${String(Date.now()).padStart(15, '0')}_${randomUUID()}`; eventStoreData.set(eventId, { eventId, message, streamId }); return eventId; }, @@ -64,6 +89,25 @@ const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQ // Sample base64 encoded minimal WAV file for testing const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA='; +// ===== MULTI-ROUND-TRIP requestState INTEGRITY (SEP-2322) ===== +// +// `requestState` round-trips through the client and comes back as +// attacker-controlled input. The SDK treats it as an opaque string and applies +// no protection of its own, so a server that lets it influence behavior MUST +// integrity-protect it when minting and MUST reject state that fails +// verification (see the migration guide). This fixture uses the SDK-provided +// `createRequestStateCodec` helper — `mint` HMAC-seals the payload with a +// per-process key and a TTL, and `verify` is the function dropped into +// `ServerOptions.requestState.verify` so the seam rejects tampered or expired +// state with `-32602` before the handler runs (which is what the +// `input-required-result-tampered-state` conformance scenario asserts). The +// key is process-local because the 2026-07-28 path serves every request from +// a fresh server instance — the state itself is the only thing that survives +// between rounds. +const requestStateCodec = createRequestStateCodec>({ + key: crypto.getRandomValues(new Uint8Array(32)) +}); + // Function to create a new MCP server instance (one per session) function createMcpServer() { const mcpServer = new McpServer( @@ -83,9 +127,19 @@ function createMcpServer() { prompts: { listChanged: true }, + // `logging` is deprecated as of protocol version 2026-07-28 + // (SEP-2577). Intentionally retained so the 2025-era + // logging/setLevel conformance leg still negotiates the + // capability; the 2026-07-28 path uses the per-request + // envelope and ignores this field. logging: {}, completions: {} - } + }, + // Seam-level integrity check (SEP-2322): every re-entered MRTR + // request that carries requestState is verified before the handler + // runs. A rejection answers a wire-level -32602 with + // data.reason 'invalid_request_state'. + requestState: { verify: requestStateCodec.verify } } ); @@ -93,7 +147,7 @@ function createMcpServer() { function sendLog( level: 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency', message: string, - _data?: unknown + data?: unknown ) { mcpServer.server .notification({ @@ -101,7 +155,7 @@ function createMcpServer() { params: { level, logger: 'conformance-test-server', - data: _data || message + data: data ?? message } }) .catch(() => { @@ -111,6 +165,27 @@ function createMcpServer() { // ===== TOOLS ===== + // SEP-2243 x-mcp-header tool — arms the http-custom-header-server-validation + // conformance scenario (which skips when no tool with an x-mcp-header + // annotation is found). The schema is hand-written JSON so the annotation + // survives serialization unchanged. + mcpServer.registerTool( + 'test_x_mcp_header', + { + description: 'Tests SEP-2243 Mcp-Param-* server-side validation', + inputSchema: fromJsonSchema<{ region?: string; level?: number }>({ + type: 'object', + properties: { + region: { type: 'string', description: 'mirrored into Mcp-Param-Region', 'x-mcp-header': 'Region' }, + level: { type: 'integer', description: 'non-mirrored argument' } + } + }) + }, + async (args): Promise => ({ + content: [{ type: 'text', text: `region=${args.region ?? ''}` }] + }) + ); + // Simple text tool mcpServer.registerTool( 'test_simple_text', @@ -243,42 +318,28 @@ function createMcpServer() { inputSchema: z.object({}) }, async (_args, ctx): Promise => { - const progressToken = ctx.mcpReq._meta?.progressToken ?? 0; - console.log('Progress token:', progressToken); - await ctx.mcpReq.notify({ - method: 'notifications/progress', - params: { - progressToken, - progress: 0, - total: 100, - message: `Completed step ${0} of ${100}` - } - }); - await new Promise(resolve => setTimeout(resolve, 50)); - - await ctx.mcpReq.notify({ - method: 'notifications/progress', - params: { - progressToken, - progress: 50, - total: 100, - message: `Completed step ${50} of ${100}` - } - }); - await new Promise(resolve => setTimeout(resolve, 50)); - - await ctx.mcpReq.notify({ - method: 'notifications/progress', - params: { - progressToken, - progress: 100, - total: 100, - message: `Completed step ${100} of ${100}` + const progressToken = ctx.mcpReq._meta?.progressToken; + // Per spec, servers MUST NOT emit notifications/progress without a + // client-supplied token — only report progress when one was sent. + if (progressToken !== undefined) { + for (const progress of [0, 50, 100]) { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { + progressToken, + progress, + total: 100, + message: `Completed step ${progress} of ${100}` + } + }); + if (progress < 100) { + await new Promise(resolve => setTimeout(resolve, 50)); + } } - }); + } return { - content: [{ type: 'text', text: String(progressToken) }] + content: [{ type: 'text', text: String(progressToken ?? 'no-progress-token') }] }; } ); @@ -340,7 +401,7 @@ function createMcpServer() { prompt: z.string().describe('The prompt to send to the LLM') }) }, - async (args: { prompt: string }, ctx): Promise => { + async (args, ctx): Promise => { try { // Request sampling from client const result = (await ctx.mcpReq.send({ @@ -391,7 +452,7 @@ function createMcpServer() { message: z.string().describe('The message to show the user') }) }, - async (args: { message: string }, ctx): Promise => { + async (args, ctx): Promise => { try { // Request user input from client const result = await ctx.mcpReq.send({ @@ -597,22 +658,48 @@ function createMcpServer() { } ); - // SEP-1613: JSON Schema 2020-12 conformance test tool + // SEP-1613 / SEP-2106: JSON Schema 2020-12 conformance test tool. + // The scenario verifies that $schema/$defs/additionalProperties (SEP-1613) + // and the broader 2020-12 vocabulary — $anchor, allOf/anyOf, if/then/else — + // (SEP-2106) survive tools/list verbatim. The schema is hand-authored JSON + // (via fromJsonSchema) so the keywords are advertised exactly as written; + // a Zod object would not emit them. mcpServer.registerTool( 'json_schema_2020_12_tool', { - description: 'Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)', - inputSchema: z.object({ - name: z.string().optional(), - address: z - .object({ - street: z.string().optional(), - city: z.string().optional() - }) - .optional() + description: 'Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613, SEP-2106)', + inputSchema: fromJsonSchema({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + $defs: { + address: { + $anchor: 'addressDef', + type: 'object', + properties: { + street: { type: 'string' }, + city: { type: 'string' } + } + } + }, + properties: { + name: { type: 'string' }, + address: { $ref: '#/$defs/address' }, + contactMethod: { type: 'string', enum: ['phone', 'email'] }, + phone: { type: 'string' }, + email: { type: 'string' } + }, + allOf: [{ anyOf: [{ required: ['phone'] }, { required: ['email'] }] }], + if: { + properties: { contactMethod: { const: 'phone' } }, + required: ['contactMethod'] + }, + // eslint-disable-next-line unicorn/no-thenable -- `then` is a JSON Schema 2020-12 keyword + then: { required: ['phone'] }, + else: { required: ['email'] }, + additionalProperties: false }) }, - async (args: { name?: string; address?: { street?: string; city?: string } }): Promise => { + async (args): Promise => { return { content: [ { @@ -624,6 +711,326 @@ function createMcpServer() { } ); + // ===== MULTI-ROUND-TRIP TOOLS (SEP-2322, protocol revision 2026-07-28) ===== + // + // Diagnostic tools for the input-required conformance scenarios. Each tool + // is written write-once style: it returns `inputRequired(...)` until the + // retried request carries the responses it needs (read from + // `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`), then completes. + // These tools are only meaningful toward 2026-07-28 requests; calling them + // on a 2025-era session fails loudly at the server seam by design. + + // Basic elicitation round trip. Also exercised by the result-type, + // missing-input-response, ignore-extra-params and validate-input + // scenarios: anything that does not contain an accepted "user_name" + // response is answered with a fresh InputRequiredResult re-requesting it. + mcpServer.registerTool( + 'test_input_required_result_elicitation', + { + description: 'MRTR (SEP-2322): asks for the caller name via an in-band elicitation request', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + const name = acceptedContent<{ name: string }>(ctx.mcpReq.inputResponses, 'user_name')?.name; + if (typeof name !== 'string') { + return inputRequired({ + inputRequests: { + user_name: inputRequired.elicit({ + message: 'What is your name?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + }) + } + }); + } + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; + } + ); + + // Basic sampling round trip. + mcpServer.registerTool( + 'test_input_required_result_sampling', + { + description: 'MRTR (SEP-2322): asks for an LLM completion via an in-band sampling request', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + const samplingResponse = ctx.mcpReq.inputResponses?.['capital_question'] as + | { content?: { type?: string; text?: string } } + | undefined; + if (samplingResponse === undefined) { + return inputRequired({ + inputRequests: { + capital_question: inputRequired.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'What is the capital of France?' } }], + maxTokens: 100 + }) + } + }); + } + const text = + typeof samplingResponse.content?.text === 'string' ? samplingResponse.content.text : JSON.stringify(samplingResponse); + return { content: [{ type: 'text', text: `Sampling response: ${text}` }] }; + } + ); + + // Basic roots/list round trip. + mcpServer.registerTool( + 'test_input_required_result_list_roots', + { + description: 'MRTR (SEP-2322): asks for the client roots via an in-band roots/list request', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + const rootsResponse = ctx.mcpReq.inputResponses?.['client_roots'] as + | { roots?: Array<{ uri?: string; name?: string }> } + | undefined; + if (!Array.isArray(rootsResponse?.roots)) { + return inputRequired({ inputRequests: { client_roots: inputRequired.listRoots() } }); + } + const uris = rootsResponse.roots.map(root => root.uri).join(', '); + return { content: [{ type: 'text', text: `Client exposed ${rootsResponse.roots.length} root(s): ${uris}` }] }; + } + ); + + // requestState round trip: the state is integrity-protected when minted + // and verified on the retry (see the helpers above). + mcpServer.registerTool( + 'test_input_required_result_request_state', + { + description: 'MRTR (SEP-2322): round-trips integrity-protected requestState alongside an elicitation request', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + const confirmation = acceptedContent<{ ok: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (confirmation === undefined) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: 'Please confirm', + requestedSchema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + required: ['ok'] + } + }) + }, + requestState: await requestStateCodec.mint({ tool: 'request_state', nonce: randomUUID() }) + }); + } + // The seam-level verify hook has already proven integrity by the + // time the handler runs; calling `verify` again here just yields + // the payload (and would re-reject if it somehow had not). + const state = ctx.mcpReq.requestState === undefined ? undefined : await requestStateCodec.verify(ctx.mcpReq.requestState, ctx); + if (state === undefined) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Invalid requestState: missing or failed integrity verification'); + } + return { content: [{ type: 'text', text: 'state-ok: requestState verified and confirmation received' }] }; + } + ); + + // Multiple input requests of different kinds in one InputRequiredResult. + mcpServer.registerTool( + 'test_input_required_result_multiple_inputs', + { + description: 'MRTR (SEP-2322): asks for elicitation, sampling and roots input in a single round', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + const responses = ctx.mcpReq.inputResponses; + const name = acceptedContent<{ name: string }>(responses, 'user_name')?.name; + const greeting = (responses?.['greeting'] as { content?: { text?: string } } | undefined)?.content?.text; + const roots = (responses?.['client_roots'] as { roots?: unknown[] } | undefined)?.roots; + if (typeof name !== 'string' || typeof greeting !== 'string' || !Array.isArray(roots)) { + return inputRequired({ + inputRequests: { + user_name: inputRequired.elicit({ + message: 'What is your name?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + }), + greeting: inputRequired.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'Generate a greeting' } }], + maxTokens: 50 + }), + client_roots: inputRequired.listRoots() + }, + requestState: await requestStateCodec.mint({ tool: 'multiple_inputs', nonce: randomUUID() }) + }); + } + return { content: [{ type: 'text', text: `${greeting} ${name} — ${roots.length} root(s) visible` }] }; + } + ); + + // Multi-round flow: the round number lives in the integrity-protected + // requestState (the 2026-07-28 path keeps no per-session state), and the + // state changes between rounds. + mcpServer.registerTool( + 'test_input_required_result_multi_round', + { + description: 'MRTR (SEP-2322): two elicitation rounds with evolving requestState before completing', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + const state = ctx.mcpReq.requestState === undefined ? undefined : await requestStateCodec.verify(ctx.mcpReq.requestState, ctx); + const round = state?.tool === 'multi_round' && typeof state.round === 'number' ? state.round : 0; + if (round === 0) { + return inputRequired({ + inputRequests: { + step1: inputRequired.elicit({ + message: 'Step 1: What is your name?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + }) + }, + requestState: await requestStateCodec.mint({ tool: 'multi_round', round: 1, nonce: randomUUID() }) + }); + } + if (round === 1) { + const name = acceptedContent<{ name: string }>(ctx.mcpReq.inputResponses, 'step1')?.name ?? 'unknown'; + return inputRequired({ + inputRequests: { + step2: inputRequired.elicit({ + message: 'Step 2: What is your favorite color?', + requestedSchema: { + type: 'object', + properties: { color: { type: 'string' } }, + required: ['color'] + } + }) + }, + requestState: await requestStateCodec.mint({ tool: 'multi_round', round: 2, name, nonce: randomUUID() }) + }); + } + const color = acceptedContent<{ color: string }>(ctx.mcpReq.inputResponses, 'step2')?.color ?? 'unknown'; + return { content: [{ type: 'text', text: `Multi-round complete: ${String(state?.name ?? 'unknown')} likes ${color}` }] }; + } + ); + + // Tampered-state rejection: the seam-level `requestState.verify` hook + // (the codec's `verify`, configured on the McpServer above) rejects a + // retry whose requestState fails HMAC before this handler runs, answering + // the wire-level -32602 the conformance scenario requires. The handler + // only sees verified state. + mcpServer.registerTool( + 'test_input_required_result_tampered_state', + { + description: 'MRTR (SEP-2322): rejects retries whose requestState fails integrity verification', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + if (ctx.mcpReq.requestState !== undefined && acceptedContent(ctx.mcpReq.inputResponses, 'confirm') !== undefined) { + return { content: [{ type: 'text', text: 'integrity-ok: requestState verified' }] }; + } + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: 'Please confirm', + requestedSchema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + required: ['ok'] + } + }) + }, + requestState: await requestStateCodec.mint({ tool: 'tampered_state', nonce: randomUUID() }) + }); + } + ); + + // Capability-aware input requests: only ask for kinds the request's + // declared client capabilities cover (the server seam enforces the same + // rule with a -32021 error; the tool simply never trips it). + mcpServer.registerTool( + 'test_input_required_result_capabilities', + { + description: 'MRTR (SEP-2322): only requests input kinds the declared client capabilities cover', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + if (ctx.mcpReq.inputResponses !== undefined) { + return { content: [{ type: 'text', text: 'Capability-aware input requests fulfilled' }] }; + } + // `sampling` and `roots` on ClientCapabilities are @deprecated as + // of protocol version 2026-07-28 (SEP-2577). This fixture reads + // them intentionally: the conformance scenario asserts that the + // server only emits input-request kinds the client declared, and + // the per-request envelope carries the declared capabilities in + // the (deprecated) wire vocabulary. + const declared = ctx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY]; + const inputRequests: InputRequests = {}; + if (declared?.elicitation !== undefined) { + inputRequests.user_name = inputRequired.elicit({ + message: 'What is your name?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + }); + } + if (declared?.sampling !== undefined) { + inputRequests.greeting = inputRequired.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'Generate a short greeting' } }], + maxTokens: 50 + }); + } + if (declared?.roots !== undefined) { + inputRequests.client_roots = inputRequired.listRoots(); + } + if (Object.keys(inputRequests).length === 0) { + return { content: [{ type: 'text', text: 'No declared client capability supports an in-band input request' }] }; + } + return inputRequired({ inputRequests }); + } + ); + + // ===== SUBSCRIPTION/LISTEN DIAGNOSTIC TRIGGERS (SEP-2575) ===== + // + // The `server-stateless` conformance scenario opens a `subscriptions/listen` + // stream (served by `createMcpHandler`'s built-in listen router), then calls + // one of these triggers and asserts the corresponding `*/list_changed` + // notification arrives on the open stream. The trigger publishes the change + // event onto the handler's bus via the `handler.notify.*` sugar — the + // listen router stamps the subscription id and applies the per-stream + // filter, so the same trigger also exercises the ack-first and + // honors-notification-filter checks. The 2026-07-28 path is per-request + // (each call gets a fresh `McpServer`), so there is no list to mutate; the + // event itself is what the SHOULD requirement measures. + + mcpServer.registerTool( + 'test_trigger_tool_change', + { + description: 'Listen diagnostic (SEP-2575): publishes a tools/list_changed event onto the handler bus', + inputSchema: z.object({}) + }, + async (): Promise => { + modernHandler.notify.toolsChanged(); + return { content: [{ type: 'text', text: 'tools_list_changed published' }] }; + } + ); + + mcpServer.registerTool( + 'test_trigger_prompt_change', + { + description: 'Listen diagnostic (SEP-2575): publishes a prompts/list_changed event onto the handler bus', + inputSchema: z.object({}) + }, + async (): Promise => { + modernHandler.notify.promptsChanged(); + return { content: [{ type: 'text', text: 'prompts_list_changed published' }] }; + } + ); + // ===== RESOURCES ===== // Static text resource @@ -703,7 +1110,7 @@ function createMcpServer() { 'test://watched-resource', { title: 'Watched Resource', - description: 'A resource that auto-updates every 3 seconds', + description: 'Static resource registered for subscribe/unsubscribe testing', mimeType: 'text/plain' }, async (): Promise => { @@ -769,7 +1176,7 @@ function createMcpServer() { arg2: z.string().describe('Second test argument') }) }, - async (args: { arg1: string; arg2: string }): Promise => { + async (args): Promise => { return { messages: [ { @@ -794,7 +1201,7 @@ function createMcpServer() { resourceUri: z.string().describe('URI of the resource to embed') }) }, - async (args: { resourceUri: string }): Promise => { + async (args): Promise => { return { messages: [ { @@ -820,6 +1227,47 @@ function createMcpServer() { } ); + // Multi-round-trip prompt (SEP-2322): prompts/get is one of the methods + // whose 2026-07-28 result vocabulary includes input_required, so a prompt + // can request elicitation input in-band before rendering. + mcpServer.registerPrompt( + 'test_input_required_result_prompt', + { + title: 'MRTR Prompt', + description: 'MRTR (SEP-2322): prompt that requires elicitation input before rendering' + }, + async (ctx): Promise => { + // A prompt registered without argsSchema receives the request + // context as its only callback argument, but the registerPrompt + // overloads only model the (args, ctx) form — so the parameter + // arrives untyped and is narrowed here. + const promptCtx = ctx as ServerContext; + const promptContext = acceptedContent<{ context: string }>(promptCtx.mcpReq.inputResponses, 'user_context')?.context; + if (typeof promptContext !== 'string') { + return inputRequired({ + inputRequests: { + user_context: inputRequired.elicit({ + message: 'What context should the prompt use?', + requestedSchema: { + type: 'object', + properties: { context: { type: 'string' } }, + required: ['context'] + } + }) + } + }); + } + return { + messages: [ + { + role: 'user', + content: { type: 'text', text: `Use the following context: ${promptContext}` } + } + ] + }; + } + ); + // Prompt with image mcpServer.registerPrompt( 'test_prompt_with_image', @@ -872,6 +1320,24 @@ 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) +}); +const modernNodeHandler = toNodeHandler(modernHandler); + +/** 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(); @@ -885,7 +1351,7 @@ app.use( cors({ origin: '*', exposedHeaders: ['Mcp-Session-Id'], - allowedHeaders: ['Content-Type', 'mcp-session-id', 'last-event-id'] + allowedHeaders: ['Content-Type', 'mcp-session-id', 'last-event-id', 'mcp-protocol-version', 'mcp-method'] }) ); @@ -894,6 +1360,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 modernNodeHandler(req, res, req.body); + return; + } + let transport: NodeStreamableHTTPServerTransport; if (sessionId && transports[sessionId]) { diff --git a/test/conformance/src/helpers/conformanceOAuthProvider.ts b/test/conformance/src/helpers/conformanceOAuthProvider.ts index 1643afab22..b35e370407 100644 --- a/test/conformance/src/helpers/conformanceOAuthProvider.ts +++ b/test/conformance/src/helpers/conformanceOAuthProvider.ts @@ -3,15 +3,20 @@ import type { OAuthClientInformationFull, OAuthClientMetadata, OAuthClientProvider, + OAuthDiscoveryState, OAuthTokens } from '@modelcontextprotocol/client'; export class ConformanceOAuthProvider implements OAuthClientProvider { + // Single-slot blob storage. The SDK stamps `issuer` onto saved values; round-tripping + // them unchanged means a credential issued by AS-A reads back as undefined at AS-B + // (SEP-2352) and the flow re-registers. private _clientInformation?: OAuthClientInformationFull; private _tokens?: OAuthTokens; private _codeVerifier?: string; private _authCode?: string; - private _authCodePromise?: Promise; + private _iss?: string; + private _discoveryState?: OAuthDiscoveryState; constructor( private readonly _redirectUrl: string | URL, @@ -47,6 +52,21 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { this._tokens = tokens; } + saveDiscoveryState(state: OAuthDiscoveryState): void { + this._discoveryState = state; + } + + discoveryState(): OAuthDiscoveryState | undefined { + return this._discoveryState; + } + + invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void { + if (scope === 'all' || scope === 'client') this._clientInformation = undefined; + if (scope === 'all' || scope === 'tokens') this._tokens = undefined; + if (scope === 'all' || scope === 'verifier') this._codeVerifier = undefined; + if (scope === 'all' || scope === 'discovery') this._discoveryState = undefined; + } + async redirectToAuthorization(authorizationUrl: URL): Promise { try { const response = await fetch(authorizationUrl.toString(), { @@ -58,6 +78,8 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { if (location) { const redirectUrl = new URL(location); const code = redirectUrl.searchParams.get('code'); + // RFC 9207: capture `iss` alongside `code` for validation before token exchange. + this._iss = redirectUrl.searchParams.get('iss') ?? undefined; if (code) { this._authCode = code; return; @@ -80,6 +102,11 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { throw new Error('No authorization code'); } + /** The `iss` parameter captured from the authorization callback (RFC 9207), or `undefined` if absent. */ + getIss(): string | undefined { + return this._iss; + } + saveCodeVerifier(codeVerifier: string): void { this._codeVerifier = codeVerifier; } diff --git a/test/conformance/src/helpers/withOAuthRetry.ts b/test/conformance/src/helpers/withOAuthRetry.ts index cbed3e2382..a156dbdff9 100644 --- a/test/conformance/src/helpers/withOAuthRetry.ts +++ b/test/conformance/src/helpers/withOAuthRetry.ts @@ -1,5 +1,11 @@ import type { FetchLike, Middleware } from '@modelcontextprotocol/client'; -import { auth, extractWWWAuthenticateParams, UnauthorizedError } from '@modelcontextprotocol/client'; +import { + auth, + computeScopeUnion, + extractWWWAuthenticateParams, + isStrictScopeSuperset, + UnauthorizedError +} from '@modelcontextprotocol/client'; import { ConformanceOAuthProvider } from './conformanceOAuthProvider.js'; @@ -9,11 +15,26 @@ export const handle401 = async ( next: FetchLike, serverUrl: string | URL ): Promise => { - const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); + const { resourceMetadataUrl, scope: challengedScope } = extractWWWAuthenticateParams(response); + // On a 403 insufficient_scope step-up, request the union of the previously + // granted scope and the challenged scope so the existing permissions are + // preserved (SEP-2350). On the initial 401 there is no prior token, so the + // union degenerates to the challenged scope. + const previousTokens = await provider.tokens(); + const scope = response.status === 403 ? computeScopeUnion(previousTokens?.scope, challengedScope) : challengedScope; + // A 401 after we already held a token means it no longer authenticates the resource; + // drop cached discovery so auth() re-probes PRM and can detect an authorization-server + // migration (SEP-2352). 403 is a step-up at the same AS — keep the cache. + if (response.status === 401) { + provider.invalidateCredentials('discovery'); + } let result = await auth(provider, { serverUrl, resourceMetadataUrl, scope, + // SEP-2350: when the union strictly exceeds the current token's granted scope, + // a refresh cannot widen it (RFC 6749 §6) — bypass refresh and re-authorize. + forceReauthorization: isStrictScopeSuperset(scope, previousTokens?.scope), fetchFn: next }); @@ -25,6 +46,7 @@ export const handle401 = async ( // await provider.waitForCallback(); const authorizationCode = await provider.getAuthCode(); + const iss = provider.getIss(); // TODO: this retry logic should be incorporated into the typescript SDK result = await auth(provider, { @@ -32,6 +54,7 @@ export const handle401 = async ( resourceMetadataUrl, scope, authorizationCode, + iss, fetchFn: next }); if (result !== 'AUTHORIZED') { @@ -47,8 +70,11 @@ export const handle401 = async ( * - Does not throw UnauthorizedError on redirect, but instead retries * - Calls next() instead of throwing for redirect-based auth * - * @param provider - OAuth client provider for authentication - * @param baseUrl - Base URL for OAuth server discovery (defaults to request URL domain) + * @param clientName - `client_name` for the auto-created ConformanceOAuthProvider (ignored when `existingProvider` is supplied) + * @param baseUrl - Base URL for OAuth server discovery (defaults to request URL origin) + * @param handle401Fn - Challenge handler invoked on 401/403 (defaults to {@link handle401}) + * @param clientMetadataUrl - CIMD URL for the auto-created provider (ignored when `existingProvider` is supplied) + * @param existingProvider - Pre-populated provider; when set, `clientName`/`clientMetadataUrl` are unused * @returns A fetch middleware function */ export const withOAuthRetry = ( @@ -84,7 +110,7 @@ export const withOAuthRetry = ( let response = await makeRequest(); - // Handle 401 responses by attempting re-authentication + // Handle 401/403 responses by attempting re-authentication if (response.status === 401 || response.status === 403) { const serverUrl = baseUrl || (typeof input === 'string' ? new URL(input).origin : input.origin); await handle401Fn(response, provider, next, serverUrl); @@ -92,7 +118,7 @@ export const withOAuthRetry = ( response = await makeRequest(); } - // If we still have a 401 after re-auth attempt, throw an error + // If we still have a 401/403 after re-auth attempt, throw an error if (response.status === 401 || response.status === 403) { const url = typeof input === 'string' ? input : input.toString(); throw new UnauthorizedError(`Authentication failed for ${url}`); diff --git a/test/e2e/CLAUDE.md b/test/e2e/CLAUDE.md index 7ecb2e06e4..586f390698 100644 --- a/test/e2e/CLAUDE.md +++ b/test/e2e/CLAUDE.md @@ -58,6 +58,27 @@ 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 its stateless legacy fallback (`legacy: 'stateless'`, the entry's default posture, passed explicitly so the arm stays era-pinned); the scenario's plain client is served per request through the fallback. Cells run on the 2025-11-25 axis only. +- `entryModern` — the entry hosted modern-only strict (`legacy: 'reject'`); the arm pins the scenario's client to the 2026-07-28 revision via `setVersionNegotiation()`, and the client attaches the per-request `_meta` envelope to every outgoing request/notification itself. Cells + run on the 2026-07-28 axis only. The pin is unconditional, so a scenario that needs to assert non-pin negotiation behavior (e.g. `mode: 'auto'` probing) must restrict off `entryModern` or drive a non-entry transport. + +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) 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 different `legacy` posture), 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`. Compositions that the entry no longer expresses through +an option (for example an existing sessionful legacy wiring routed via `isLegacyRequest` next to a strict entry) are hosted by the test body itself behind an in-process fetch — see `scenarios/hosting-entry-session.test.ts`. + ## 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..31cf9b7e22 --- /dev/null +++ b/test/e2e/fixtures/dual-era-stdio-server.ts @@ -0,0 +1,28 @@ +/** + * Runnable dual-era stdio MCP server fixture for the dual-era stdio e2e cells. + * + * The connection-pinned `serveStdio` entry over an ordinary `McpServer` + * factory: the client's opening exchange selects the era for the connection + * (a 2025 `initialize` handshake or 2026-07-28 per-request envelope traffic + * negotiated via `server/discover`), and one factory instance serves it. + * 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 { serveStdio } from '@modelcontextprotocol/server/stdio'; +import { z } from 'zod/v4'; + +serveStdio(() => { + const server = new McpServer({ name: 'dual-era-stdio-e2e-fixture', version: '1.0.0' }, { capabilities: { tools: {} } }); + 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 }] }) + ); + return server; +}); +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..68935b8f2d 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -15,30 +15,101 @@ 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 +} 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'; +/** Narrows away `null`/`undefined` for values the surrounding test has already proven exist (replaces non-null assertions). */ +export function defined(value: T | null | undefined, label: string): NonNullable { + if (value === null || value === undefined) throw new Error(`expected ${label} to be defined`); + return value; +} + 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 HTTP request headers as resolved by `new Request(...)` — for raw header assertions (e.g. `Mcp-Param-*`). */ + requestHeaders: Headers; + /** 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 entry's default posture, + * passed explicitly so the arm stays pinned to the 2025 leg even if the + * default ever moves) and `{ legacy: 'reject' }` (modern-only strict) 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 +138,57 @@ 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 stateless legacy fallback (the default, + // passed explicitly to keep the arm era-pinned); `entryModern` hosts the + // endpoint modern-only strict (`legacy: 'reject'` — strict is no longer + // the entry default) and pins the scenario's client to the 2026-07-28 + // revision via the public negotiation setter. The client attaches the + // per-request `_meta` envelope itself once a modern era is negotiated, + // so no harness wrap is needed. Every HTTP exchange is recorded on + // `httpLog`. + const handler = createMcpHandler( + makeServer, + transport === 'entryStateless' ? { legacy: 'stateless', ...sniff.entry } : { legacy: 'reject', ...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(), + requestHeaders: request.headers, + ...(typeof init?.body === 'string' && { requestBody: init.body }), + status: response.status, + contentType: response.headers.get('content-type') ?? '', + response: response.clone() + }); + return response; + }; + const clientTx = new StreamableHTTPClientTransport(url, { fetch }); + // entryModern is the era-fixed 2026-07-28 arm: it is the only arm + // whose wire may legitimately carry input_required results, so it + // opts the sniffer into accepting them (other arms stay strict). + let armSniff: WireOptions = sniff; + if (transport === 'entryModern') { + client.setVersionNegotiation({ mode: { pin: MODERN_REVISION } }); + armSniff = { allowInputRequiredResults: true, ...sniff }; + } + await client.connect(sniffTransport(clientTx, 'client', armSniff)); + 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 +334,44 @@ 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 client attaches the envelope itself once a modern era is + * negotiated. + */ +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]: {} + }; +} + +/** + * 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'}` + ); + } +} + // ─────────────────────────────────────────────────────────────────────────────── // 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/helpers/wire-sniffer.test.ts b/test/e2e/helpers/wire-sniffer.test.ts index 73ea7222e8..ca072217a9 100644 --- a/test/e2e/helpers/wire-sniffer.test.ts +++ b/test/e2e/helpers/wire-sniffer.test.ts @@ -61,6 +61,19 @@ describe('assertWireMessage', () => { expect(() => assertWireMessage(req('sampling/createMessage', { messages: [], maxTokens: 1 }), 'server')).not.toThrow(); }); + it('rejects an input_required server result unless the cell opted in (modern-era arms only)', () => { + const inputRequired = resp({ + resultType: 'input_required', + inputRequests: { ask: { method: 'elicitation/create', params: { mode: 'form', message: 'Name?' } } } + }); + // Default (legacy-era cells): input_required is not legal wire vocabulary. + expect(() => assertWireMessage(inputRequired, 'server')).toThrow(/invalid message/); + // Modern-era arms opt in explicitly. + expect(() => assertWireMessage(inputRequired, 'server', { allowInputRequiredResults: true })).not.toThrow(); + // The opt-in never applies to client-sent results. + expect(() => assertWireMessage(inputRequired, 'client', { allowInputRequiredResults: true })).toThrow(/invalid message/); + }); + it('accepts a JSON-RPC error response for either party', () => { const err = { jsonrpc: '2.0' as const, id: 1, error: { code: -32_601, message: 'Method not found' } }; expect(() => assertWireMessage(err, 'server')).not.toThrow(); diff --git a/test/e2e/helpers/wire-sniffer.ts b/test/e2e/helpers/wire-sniffer.ts index 89663214ce..3a5dc2fc3f 100644 --- a/test/e2e/helpers/wire-sniffer.ts +++ b/test/e2e/helpers/wire-sniffer.ts @@ -8,6 +8,7 @@ import { } from '@modelcontextprotocol/core'; import type { Transport } from '@modelcontextprotocol/server'; import { + isInputRequiredResult, isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, @@ -22,6 +23,13 @@ export interface SnifferOptions { allowCustomMethods?: boolean; /** `false` → envelope check only (for tests that deliberately send malformed messages). */ strictValidation?: boolean; + /** + * Permit `input_required` results as server output. Set automatically by + * the wiring for the modern-era (2026-07-28) arms — multi-round-trip + * results are not legal vocabulary on the 2025-era wire, so an + * `input_required` leaking onto a legacy cell is flagged. + */ + allowInputRequiredResults?: boolean; } const OUTBOUND = { @@ -87,6 +95,12 @@ export function assertWireMessage(msg: unknown, party: WireParty, opts: SnifferO if (isJSONRPCResultResponse(msg)) { const result = (msg as { result: unknown }).result; + // Multi-round-trip results (protocol revision 2026-07-28) are valid + // server output but deliberately NOT part of the neutral result union + // (InputRequiredResultSchema lives alongside, never widening it). + // Era-gated: only cells wired for the modern era opt in, so an + // input_required on a 2025-era cell's wire is still flagged. + if (party === 'server' && opts.allowInputRequiredResults === true && isInputRequiredResult(result)) return; const r = schemas.result.safeParse(result); if (!r.success) { // A result for a vendor-extension request legitimately won't match the spec union. diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index ea471a21fc..1615d93997 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.' @@ -120,7 +128,10 @@ export const REQUIREMENTS: Record = { 'protocol:cancel:abort-signal': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation#cancellation-flow', behavior: - 'Cancelling an in-flight request through the client API sends notifications/cancelled with the request id and fails the local call.' + 'Cancelling an in-flight request through the client API sends notifications/cancelled with the request id and fails the local call.', + removedInSpecVersion: '2026-07-28', + supersededBy: 'protocol:cancel:http-stream-close', + note: '2026-07-28 makes Streamable-HTTP cancellation a per-request stream-close (no notifications/cancelled on the wire); the supersedes link names that surface. stdio at the modern era still POSTs cancelled but no modern stdio cell exists in the matrix yet.' }, 'protocol:cancel:handler-abort-propagates': { transports: STATEFUL_TRANSPORTS, @@ -150,10 +161,32 @@ 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:connect:prior-zero-roundtrip': { + source: 'sdk', + behavior: + 'connect(transport, { prior: DiscoverResult }) against a 2026-07-28 server is zero-round-trip: a fresh client supplied with a previously-obtained DiscoverResult connects without putting any HTTP exchange on the wire, adopts the modern era directly, and callTool round-trips immediately. prior is modern-only — no modern overlap throws SdkError(EraNegotiationFailed) (no legacy fallback).', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'Runs on the entryModern arm; the wired (negotiating) client is the bootstrap that obtains the DiscoverResult, then a fresh worker client connects to the same harness-hosted endpoint via wired.url + a fresh StreamableHTTPClientTransport over wired.fetch with { prior }. The zero-round-trip clause is asserted on the arm-recorded httpLog length.' + }, + '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 +206,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.' }, @@ -227,9 +261,28 @@ export const REQUIREMENTS: Record = { }, 'protocol:timeout:sends-cancellation': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts', - behavior: 'When a request times out, the sender issues notifications/cancelled for that request before failing the local call.' + behavior: 'When a request times out, the sender issues notifications/cancelled for that request before failing the local call.', + removedInSpecVersion: '2026-07-28', + supersededBy: 'protocol:cancel:http-stream-close', + note: '2026-07-28 makes Streamable-HTTP timeout cancellation a per-request stream-close (no notifications/cancelled on the wire); the supersedes link names that surface.' + }, + 'protocol:cancel:http-stream-close': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/cancellation#transport-specific-cancellation', + behavior: + 'On a 2026-07-28 Streamable HTTP connection, cancelling an in-flight client request (caller signal or timeout) closes that request’s SSE response stream as the spec cancellation signal; no notifications/cancelled message is sent on the wire and the local call fails.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + supersedes: ['protocol:cancel:abort-signal', 'protocol:timeout:sends-cancellation'], + note: 'Streamable-HTTP only; stdio at the modern era still POSTs notifications/cancelled (no modern stdio cell exists in the matrix yet).' }, '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 +300,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 +334,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.' @@ -309,6 +370,8 @@ export const REQUIREMENTS: Record = { transports: STATEFUL_TRANSPORTS, source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#user-interaction-model', behavior: "A tool handler that issues an elicitation receives the client's result and can embed it in the tool call result.", + removedInSpecVersion: '2026-07-28', + supersededBy: 'elicitation:mrtr:form:basic', 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.' }, 'tools:call:is-error': { @@ -336,6 +399,8 @@ export const REQUIREMENTS: Record = { source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/sampling', behavior: "A tool handler that issues a sampling request receives the client's completion and can embed it in the tool call result.", + removedInSpecVersion: '2026-07-28', + supersededBy: 'sampling:mrtr:create:basic', 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.' }, 'tools:call:structured-content': { @@ -351,6 +416,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.' }, @@ -375,6 +447,8 @@ export const REQUIREMENTS: Record = { transports: STATEFUL_TRANSPORTS, source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#list-changed-notification', behavior: "When the tool set changes, the server sends notifications/tools/list_changed and it reaches the client's handler.", + removedInSpecVersion: '2026-07-28', + supersededBy: 'tools:listen:list-changed', 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.' }, 'tools:list:basic': { @@ -382,6 +456,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.' @@ -389,13 +470,7 @@ export const REQUIREMENTS: Record = { 'tools:list:pagination': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#listing-tools', behavior: - 'tools/list supports cursor pagination: the nextCursor returned by a list handler round-trips back to the handler as an opaque cursor until the listing is exhausted.', - knownFailures: [ - { - test: 'mcpserver', - note: 'McpServer does not implement automatic pagination — handlers receive the cursor but the high-level API returns the full list with no nextCursor unless the user implements cursor handling in their own handler.' - } - ] + 'tools/list supports cursor pagination: the nextCursor returned by a list handler round-trips back to the handler as an opaque cursor until the listing is exhausted.' }, 'tools:call:concurrent': { source: 'sdk', @@ -430,6 +505,129 @@ export const REQUIREMENTS: Record = { source: 'sdk', behavior: 'Server-side output schema validation is skipped when the tool returns an isError result.' }, + + // Tools: JSON Schema 2020-12 validator posture (SEP-1613 / SEP-2106) + + 'client:jsonschema:same-document-ref-ok': { + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#output-schema', + behavior: + 'A tool whose advertised outputSchema uses same-document $ref ("#/$defs/…" or "#anchor") compiles on the client and validates structuredContent against the referenced subschema.' + }, + 'client:jsonschema:unsupported-dialect-graceful': { + source: 'sdk', + behavior: + 'A tool whose advertised outputSchema declares a $schema dialect URI the built-in validator does not recognise is refused gracefully on the client: callTool throws InvalidParams with a clear "unsupported dialect … 2020-12 only" message instead of having the underlying engine fail opaquely.' + }, + 'client:jsonschema:bad-schema-isolates-tool': { + source: 'sdk', + behavior: + 'One bad outputSchema in a tools/list response (a schema the validator engine refuses to compile — e.g. an unresolvable external $ref) does not poison the listing: tools/list resolves with every tool present, callTool on the bad tool throws InvalidParams, and callTool on the other tools succeeds.' + }, + 'client:jsonschema:non-object-output': { + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#output-schema', + behavior: + 'A tool whose advertised outputSchema has a non-object root (e.g. type:"array") is accepted by the client validator on the 2026-07-28 era: structuredContent matching that root validates and is returned typed unknown.', + note: 'Restricted to the entryModern arm because the 2025-era wire codec keeps outputSchema/structuredContent at their type:"object" / Record shapes (byte-identity), so a non-object root only round-trips natively on the 2026-07-28 path.' + }, + 'client:jsonschema:2020-12:prefixItems': { + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#output-schema', + behavior: + 'The default client validator enforces JSON Schema 2020-12 vocabulary: a tool whose advertised outputSchema uses prefixItems rejects structuredContent that violates the per-index item schemas (a draft-07 engine with strict:false would silently ignore prefixItems and accept).', + note: 'Restricted to the entryModern arm because the array-typed outputSchema/structuredContent only round-trip natively on the 2026-07-28 wire codec.' + }, + 'client:jsonschema:dialect:default-is-2020-12': { + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#output-schema', + behavior: + 'A tool whose advertised outputSchema declares no $schema is validated by the client with the 2020-12 engine (the default): a 2020-12-only keyword (prefixItems) in the schema is enforced, so structuredContent violating it causes callTool to throw InvalidParams.', + note: 'Restricted to the entryModern arm so the schema (carrying prefixItems) round-trips through the 2026-07-28 wire codec verbatim.' + }, + 'client:jsonschema:falsy-structured-content-validated': { + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#structured-content', + behavior: + 'A falsy structuredContent value (0, false, "", null) is treated as present by the client and validated against the cached outputSchema — the presence check is `=== undefined`, not falsy, so a tool returning structuredContent: 0 against outputSchema {type:"integer"} resolves with the value rather than throwing "did not return structured content".', + note: 'Restricted to the entryModern arm because primitive structuredContent only round-trips natively on the 2026-07-28 wire codec.' + }, + 'server:jsonschema:array-structured-content-textfallback': { + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#structured-content', + behavior: + 'A McpServer tool whose handler returns array-typed structuredContent and no text content has a {type:"text", text: JSON.stringify(structuredContent)} block auto-appended (the SEP-2106 backward-compatibility fallback) so legacy-style consumers still receive a rendering. An author-supplied text block suppresses the auto-append.', + note: 'Runs on the entryModern arm so the array structuredContent round-trips natively.' + }, + 'server:jsonschema:primitive-structured-content': { + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#structured-content', + behavior: + 'A McpServer tool whose handler returns primitive (string / number / boolean / null) structuredContent round-trips on the 2026-07-28 era: the value reaches the client as typed unknown and the auto TextContent fallback carries its JSON serialisation.', + note: 'Runs on the entryModern arm so a non-object structuredContent round-trips natively.' + }, + '2025:jsonschema:non-object-output-wrapped': { + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + source: 'sdk', + behavior: + 'On a 2025-era listing, a McpServer tool registered with a non-object-root outputSchema has the outputSchema wrapped in {type:"object",properties:{result:},required:["result"]} (the SEP-2106 legacy interop envelope): the tool stays listed, the schema is valid 2025 wire data, and a 2025 client can compile/validate against the wrapped shape.', + note: 'Bounded to the 2025-11-25 axis on the entryStateless arm: a statement about what 2025-era clients see when served by a SEP-2106-aware server.' + }, + '2025:jsonschema:non-object-structured-content-wrapped': { + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + source: 'sdk', + behavior: + 'On a 2025-era tools/call, a McpServer tool whose handler returns non-object structuredContent (array/primitive/null) has the auto-TextContent fallback injected and the structuredContent wrapped as {result:}: the result satisfies both the 2025 wire shape (object-only) and the wrapped outputSchema advertised in tools/list.', + note: 'Bounded to the 2025-11-25 axis on the entryStateless arm. The result-side mirror of the legacy outputSchema wrap.' + }, + '2025:jsonschema:ref-rewrite-on-wrap': { + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + source: 'sdk', + behavior: + 'On a 2025-era listing, a non-object outputSchema with same-document $ref JSON Pointers ("#", "#/…") wrapped under #/properties/result has every such $ref rewritten to keep resolving: the wrapped schema compiles on the client and validates the wrapped {result:…} structuredContent.', + note: 'Bounded to the 2025-11-25 axis on the entryStateless arm. Mirrors the C# SDK TransformOutputSchemaForLegacyWire.' + }, + '2025:jsonschema:ref-rewrite-scope': { + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + source: 'sdk', + behavior: + 'The legacy-wrap $ref rewrite is position-aware: it applies to $ref AND $dynamicRef in subschema positions, but NOT to keyword-position data (const/enum/default/examples) where a {$ref:…} is a literal value; a property NAMED default/const under properties/$defs IS recursed into. The rewrite is $id-scoped: a natural schema (or any subtree) carrying $id keeps its same-document refs unrewritten — they resolve against the embedded base, not the wrapper root.', + note: 'Bounded to the 2025-11-25 axis on the entryStateless arm. Goes beyond the C# RewriteRefPointers on both points.' + }, + '2025:jsonschema:schemaless-non-object-sc-wrapped': { + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + source: 'sdk', + behavior: + 'On a 2025-era tools/call, a tool with NO advertised outputSchema whose handler returns non-object structuredContent (array/primitive/null) has the value wrapped as {result:} regardless: the 2025 wire shape requires structuredContent to be an object, so the projection wraps on value shape alone when there is no schema to consult.', + note: 'Bounded to the 2025-11-25 axis on the entryStateless arm. The schema-less twin of 2025:jsonschema:non-object-structured-content-wrapped.' + }, + '2025:jsonschema:wrap-follows-schema-not-value': { + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + source: 'sdk', + behavior: + 'On the 2025 era, a McpServer tool whose outputSchema has a non-object root (e.g. z.union([z.object(...), z.string()]) → typeless {anyOf:[…]}) wraps EVERY structuredContent value as {result:} — including object-valued results — so the result always satisfies the wrapped outputSchema advertised in tools/list. The wrap predicate follows the per-tool schema decision, not the runtime value shape.', + note: 'Bounded to the 2025-11-25 axis on the entryStateless arm. The schema-side mirror is 2025:jsonschema:non-object-output-wrapped.' + }, + 'server:jsonschema:union-output-natural': { + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + source: 'sdk', + behavior: + 'On the 2026 era, a McpServer tool whose outputSchema is z.union([z.object(...), z.string()]) advertises the natural typeless {anyOf:[…]} root and returns structuredContent unwrapped on both branches (object and string); the era-agnostic auto-TextContent fallback still fires for the non-object branch.', + note: 'Runs on the entryModern arm so the typeless-root outputSchema and primitive structuredContent round-trip natively.' + }, + 'mcpserver:tool:duplicate-name': { source: 'sdk', behavior: 'Registering a tool with a name already in use is rejected at registration time.' @@ -462,7 +660,10 @@ export const REQUIREMENTS: Record = { 'mcpserver:tool:url-elicitation-error': { source: 'sdk', behavior: - 'A tool function that raises the URL-elicitation-required error surfaces to the caller as error -32042 with the elicitation parameters intact.' + 'A tool function that raises the URL-elicitation-required error surfaces to the caller as error -32042 with the elicitation parameters intact.', + removedInSpecVersion: '2026-07-28', + supersededBy: 'typescript:mrtr:url-elicitation:no-32042-on-2026', + note: 'The body asserts the legacy -32042 error surface; on the 2026-07-28 era URL elicitation rides multi round-trip results instead (the supersedes link names that surface).' }, 'typescript:mcpserver:tool:schema-variants': { source: 'sdk', @@ -491,6 +692,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.' @@ -500,6 +708,8 @@ export const REQUIREMENTS: Record = { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/resources#list-changed-notification', behavior: "When the resource set changes, the server sends notifications/resources/list_changed and it reaches the client's handler.", + removedInSpecVersion: '2026-07-28', + supersededBy: 'resources:listen:list-changed', 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.' }, 'resources:list:basic': { @@ -509,13 +719,7 @@ export const REQUIREMENTS: Record = { }, 'resources:list:pagination': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/resources#listing-resources', - behavior: 'resources/list supports cursor pagination.', - knownFailures: [ - { - test: 'mcpserver', - note: 'McpServer does not implement automatic pagination — handlers receive the cursor but the high-level API returns the full list with no nextCursor unless the user implements cursor handling in their own handler.' - } - ] + behavior: 'resources/list supports cursor pagination.' }, 'resources:read:blob': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/resources#reading-resources', @@ -530,10 +734,12 @@ export const REQUIREMENTS: Record = { behavior: 'resources/read returns text contents carrying uri, mimeType, and the text.' }, 'resources:read:unknown-uri': { - source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/resources#error-handling', - behavior: 'resources/read for an unknown URI returns JSON-RPC error -32002 (resource not found).' + source: 'https://modelcontextprotocol.io/specification/draft/server/resources#error-handling', + behavior: + 'resources/read for an unknown URI returns JSON-RPC error -32602 (Invalid Params) with data.uri echoing the requested URI; clients also recognise -32002 from older peers. Servers do not return an empty contents array for a non-existent resource.' }, '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.' }, @@ -549,13 +755,7 @@ export const REQUIREMENTS: Record = { }, 'resources:templates:pagination': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination#operations-supporting-pagination', - behavior: 'resources/templates/list supports cursor pagination.', - knownFailures: [ - { - test: 'mcpserver', - note: 'McpServer does not implement automatic pagination — handlers receive the cursor but the high-level API returns the full list with no nextCursor unless the user implements cursor handling in their own handler.' - } - ] + behavior: 'resources/templates/list supports cursor pagination.' }, 'resources:unsubscribe:stops-updates': { transports: STATEFUL_TRANSPORTS, @@ -598,6 +798,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.' }, @@ -633,6 +840,8 @@ export const REQUIREMENTS: Record = { transports: STATEFUL_TRANSPORTS, source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/prompts#list-changed-notification', behavior: "When the prompt set changes, the server sends notifications/prompts/list_changed and it reaches the client's handler.", + removedInSpecVersion: '2026-07-28', + supersededBy: 'prompts:listen:list-changed', 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.' }, 'prompts:list:basic': { @@ -641,13 +850,7 @@ export const REQUIREMENTS: Record = { }, 'prompts:list:pagination': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/prompts#listing-prompts', - behavior: 'prompts/list supports cursor pagination.', - knownFailures: [ - { - test: 'mcpserver', - note: 'McpServer does not implement automatic pagination — handlers receive the cursor but the high-level API returns the full list with no nextCursor unless the user implements cursor handling in their own handler.' - } - ] + behavior: 'prompts/list supports cursor pagination.' }, 'prompts:get:multi-message': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/prompts#getting-a-prompt', @@ -709,6 +912,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 +930,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 +954,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: [ @@ -779,12 +991,16 @@ export const REQUIREMENTS: Record = { source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/sampling#creating-messages', behavior: "A sampling/createMessage request from a server handler is answered by the client's sampling callback, and the callback's result (role, content, model, stopReason) is returned to the handler.", + removedInSpecVersion: '2026-07-28', + supersededBy: 'sampling:mrtr:create:basic', 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.' }, 'sampling:create:include-context': { transports: STATEFUL_TRANSPORTS, source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/sampling#capabilities', behavior: 'The includeContext value supplied by the server reaches the client callback intact.', + removedInSpecVersion: '2026-07-28', + supersededBy: 'sampling:mrtr:create:include-context', 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.' }, 'sampling:create:model-preferences': { @@ -792,12 +1008,16 @@ export const REQUIREMENTS: Record = { source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/sampling#model-preferences', behavior: 'The model preferences supplied by the server (hints and the cost, speed, and intelligence priorities) reach the client callback intact.', + removedInSpecVersion: '2026-07-28', + supersededBy: 'sampling:mrtr:create:model-preferences', 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.' }, 'sampling:create:system-prompt': { transports: STATEFUL_TRANSPORTS, source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/sampling#creating-messages', behavior: 'The system prompt supplied by the server reaches the client callback intact.', + removedInSpecVersion: '2026-07-28', + supersededBy: 'sampling:mrtr:create:system-prompt', 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.' }, 'sampling:create:tools': { @@ -916,18 +1136,24 @@ export const REQUIREMENTS: Record = { transports: STATEFUL_TRANSPORTS, source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#response-actions', behavior: "A form-mode elicitation answered with action 'accept' returns the user's content to the requesting handler.", + removedInSpecVersion: '2026-07-28', + supersededBy: 'elicitation:mrtr:form:basic', 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.' }, 'elicitation:form:action:cancel': { transports: STATEFUL_TRANSPORTS, source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#response-actions', behavior: "A form-mode elicitation answered with action 'cancel' returns no content to the handler.", + removedInSpecVersion: '2026-07-28', + supersededBy: 'elicitation:mrtr:form:action:cancel', 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.' }, 'elicitation:form:action:decline': { transports: STATEFUL_TRANSPORTS, source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#response-actions', behavior: "A form-mode elicitation answered with action 'decline' returns no content to the handler.", + removedInSpecVersion: '2026-07-28', + supersededBy: 'elicitation:mrtr:form:action:decline', 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.' }, 'elicitation:form:basic': { @@ -935,6 +1161,8 @@ export const REQUIREMENTS: Record = { source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#form-mode-elicitation-requests', behavior: 'A form-mode elicitation delivers the message and requested schema to the client callback exactly as the server sent them.', + removedInSpecVersion: '2026-07-28', + supersededBy: 'elicitation:mrtr:form:basic', 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.' }, 'elicitation:form:defaults': { @@ -959,6 +1187,8 @@ export const REQUIREMENTS: Record = { transports: STATEFUL_TRANSPORTS, source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#requested-schema', behavior: 'Requested-schema fields may be string (with format), number or integer, or boolean.', + removedInSpecVersion: '2026-07-28', + supersededBy: 'elicitation:mrtr:form:schema:primitives', 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.' }, 'elicitation:url:action:accept-no-content': { @@ -984,12 +1214,17 @@ export const REQUIREMENTS: Record = { 'elicitation:url:complete-unknown-ignored': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#completion-notifications-for-url-mode-elicitation', behavior: - 'The client ignores an elicitation/complete notification referencing an unknown or already-completed elicitationId without error.' + 'The client ignores an elicitation/complete notification referencing an unknown or already-completed elicitationId without error.', + removedInSpecVersion: '2026-07-28', + note: 'Retired on the 2026-07-28 era: notifications/elicitation/complete is removed from the draft schema (spec PR #2891), so there is no notification for the modern client to ignore.' }, 'elicitation:url:required-error': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#url-elicitation-required-error', behavior: - 'A handler that cannot proceed without a URL elicitation rejects the request with error -32042, carrying the pending elicitations in the error data.' + 'A handler that cannot proceed without a URL elicitation rejects the request with error -32042, carrying the pending elicitations in the error data.', + removedInSpecVersion: '2026-07-28', + supersededBy: 'typescript:mrtr:url-elicitation:no-32042-on-2026', + note: 'The body asserts the legacy -32042 error surface; on the 2026-07-28 era URL elicitation rides multi round-trip results instead (the supersedes link names that surface).' }, 'elicitation:form:response-validation': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#form-mode-security', @@ -1038,6 +1273,8 @@ export const REQUIREMENTS: Record = { source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/roots#listing-roots', behavior: "A roots/list request from a server handler is answered by the client's roots callback, and the returned roots (uri, name) reach the handler.", + removedInSpecVersion: '2026-07-28', + supersededBy: 'roots:mrtr:list:basic', 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.' }, 'roots:list:client-error': { @@ -1056,6 +1293,8 @@ export const REQUIREMENTS: Record = { source: 'https://modelcontextprotocol.io/specification/2025-11-25/client/roots#listing-roots', behavior: 'An empty roots list is a valid response and reaches the handler as such.', transports: ['inMemory', 'stdio', 'streamableHttp'], + removedInSpecVersion: '2026-07-28', + supersededBy: 'roots:mrtr:list:empty', 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.' }, @@ -1066,6 +1305,8 @@ export const REQUIREMENTS: Record = { source: 'sdk', behavior: 'A client configured to react to list_changed notifications automatically re-fetches the corresponding list and delivers the fresh result to its callback.', + removedInSpecVersion: '2026-07-28', + supersededBy: 'client:listen:auto-refresh', 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.' }, 'client:list-changed:capability-gated': { @@ -1127,11 +1368,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.' @@ -1793,6 +2036,41 @@ export const REQUIREMENTS: Record = { transports: ['streamableHttp'], note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, + 'client-auth:stepup:scope-union': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#step-up-authorization-flow', + behavior: + 'On 403 insufficient_scope the transport re-authorizes with the union of its previously-requested scope and the challenged scope (computeScopeUnion); the union is a plain string-set dedup with no hierarchical collapse.', + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:stepup:retry-cap': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#step-up-authorization-flow', + behavior: + 'Step-up re-authorization is bounded per send by maxStepUpRetries (default 1), independent of WWW-Authenticate header content; reaching the cap throws an SdkHttpError without further auth() calls.', + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:stepup:throw-mode': { + source: 'sdk', + behavior: + "With onInsufficientScope: 'throw', a 403 insufficient_scope throws InsufficientScopeError carrying {requiredScope, resourceMetadataUrl, errorDescription} and never calls auth().", + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:stepup:get-stream-403': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#step-up-authorization-flow', + behavior: + 'The GET listen-stream open path applies the same 403 insufficient_scope step-up handling as the POST send path (same throw-mode short-circuit, same scope union, same per-open retry cap).', + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:stepup:refresh-bypass-on-superset': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#step-up-authorization-flow', + behavior: + "On 403 insufficient_scope step-up: when the union scope is a strict superset of the current token's granted scope, auth() bypasses the refresh-token branch (forceReauthorization) and forces a fresh authorization request so the widened scope reaches the AS; when the token already covers the union, refresh is used.", + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, 'client-auth:as-metadata-discovery:priority-order': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery', behavior: @@ -1935,12 +2213,71 @@ export const REQUIREMENTS: Record = { behavior: 'The client rejects authorization-server metadata whose issuer does not match the URL the metadata was retrieved from (RFC 8414 section 3.3).', transports: ['streamableHttp'], - note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.', - knownFailures: [ - { - note: 'discoverAuthorizationServerMetadata never validates that the returned issuer matches the authorization-server URL the metadata was fetched from (RFC 8414 section 3.3), so spoofed-issuer metadata is accepted and the OAuth flow proceeds to registration and redirect.' - } - ] + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:iss:match': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-response-issuer-validation', + behavior: + "When the authorization callback's iss exactly matches the issuer recorded from validated AS metadata, finishAuth() proceeds to redeem the authorization code (RFC 9207 §2.4, table row 1).", + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:iss:mismatch-reject': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-response-issuer-validation', + behavior: + "When the authorization callback's iss differs from the recorded issuer, the client throws IssuerMismatchError (kind 'authorization_response') and does not transmit the authorization code to any token endpoint.", + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:iss:supported-missing-reject': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-response-issuer-validation', + behavior: + 'When the AS metadata advertises authorization_response_iss_parameter_supported: true and the callback carries no iss, the client throws IssuerMismatchError before redeeming the code (table row 2).', + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:iss:unadvertised-proceed': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-response-issuer-validation', + behavior: + 'When the AS metadata does not advertise authorization_response_iss_parameter_supported and the callback carries no iss, the client proceeds with the code exchange (table row 4).', + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:iss:no-normalize': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-response-issuer-validation', + behavior: + 'iss comparison is simple string comparison only — scheme/host case folding, default-port elision, trailing-slash, and percent-encoding normalization are NOT applied; any such difference is rejected as a mismatch.', + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:iss:opt-out': { + addedInSpecVersion: '2026-07-28', + source: 'sdk', + behavior: + 'AuthOptions.skipIssuerMetadataValidation: true suppresses only the RFC 8414 §3.3 metadata-issuer-echo check (AU-02) — it does not relax the RFC 9207 callback-iss validation, which continues to reject mismatches.', + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:finishauth:urlsearchparams-sanitizes': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-response-issuer-validation', + behavior: + "transport.finishAuth(URLSearchParams) extracts code and iss, validates iss against the recorded issuer first, and on mismatch throws IssuerMismatchError without surfacing the callback's error/error_description/error_uri values; the authorization code is never sent to a token endpoint.", + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'hosting:auth:as-iss-emission': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-response-issuer-validation', + behavior: + "The bundled authorization server (mcpAuthRouter from @modelcontextprotocol/server-legacy) advertises authorization_response_iss_parameter_supported (default true; derived from the provider) and its authorize handler appends iss (RFC 9207 §2) to every redirect — success and error — issued to the client's redirect_uri without requiring OAuthServerProvider.authorize() to do so.", + transports: ['streamableHttp'], + note: 'These exercise the HTTP hosting/auth layer (mostly over real Express); the matrix transport arg is ignored, so they run as a single streamableHttp-labelled cell to avoid duplicate runs.' }, 'client-auth:prm-discovery:no-prm-fallback': { source: 'sdk', @@ -1949,6 +2286,100 @@ export const REQUIREMENTS: Record = { transports: ['streamableHttp'], note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, + 'client-auth:dcr:app-type-heuristic': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration#application-type', + behavior: + "When clientMetadata.application_type is omitted, Dynamic Client Registration defaults it from the redirect URIs: a loopback host or custom URI scheme yields 'native', otherwise 'web' (SEP-837).", + transports: ['streamableHttp'], + addedInSpecVersion: '2026-07-28', + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:dcr:app-type-override': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration#application-type', + behavior: + 'A consumer-set clientMetadata.application_type is sent verbatim in Dynamic Client Registration; the SDK heuristic never overwrites it.', + transports: ['streamableHttp'], + addedInSpecVersion: '2026-07-28', + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:dcr:registration-rejected-error': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration#application-type', + behavior: + "When the authorization server rejects Dynamic Client Registration, the SDK throws RegistrationRejectedError carrying the HTTP status, raw body, and the submitted metadata so callers can retry with adjusted metadata; the auth() orchestrator's OAuthError retry path does not swallow it.", + transports: ['streamableHttp'], + addedInSpecVersion: '2026-07-28', + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:dcr:grant-types-default': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#refresh-token-grant', + behavior: + "When clientMetadata.grant_types is omitted, Dynamic Client Registration defaults it to ['authorization_code', 'refresh_token'] so authorization servers may issue refresh tokens (SEP-2207); a consumer-set grant_types is never rewritten.", + transports: ['streamableHttp'], + addedInSpecVersion: '2026-07-28', + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:token-endpoint:https-guard': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#refresh-token-grant', + behavior: + "The token-exchange and refresh paths refuse to send credentials to a non-https token endpoint (localhost / 127.0.0.1 / ::1 exempt) by throwing InsecureTokenEndpointError, and auth()'s refresh branch surfaces it instead of falling through to a fresh /authorize redirect.", + transports: ['streamableHttp'], + addedInSpecVersion: '2026-07-28', + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:refresh:rotation-handling': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#refresh-token-grant', + behavior: + 'On refresh, a new refresh_token returned by the AS replaces the prior one; if the AS omits refresh_token the prior one is preserved; the SDK never assumes a refresh_token will be issued (SEP-2207).', + transports: ['streamableHttp'], + note: 'Verify-only pin of behavior already correct at the v2 baseline. Runs as a single streamableHttp-labelled cell.' + }, + 'client-auth:scope:offline-access-gate': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#refresh-token-grant', + behavior: + "The client appends offline_access to the requested scope only when the authorization server's metadata advertises it in scopes_supported and the client's grant_types includes refresh_token (SEP-2207).", + transports: ['streamableHttp'], + note: 'Verify-only pin of behavior already correct at the v2 baseline. Runs as a single streamableHttp-labelled cell.' + }, + 'client-auth:as-migration:reregister': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration#authorization-server-migration', + behavior: + "When the protected resource's authorization_servers list changes to a different issuer, auth() reads back the issuer-stamped client credential as undefined (key not found) and re-runs Dynamic Client Registration at the new authorization server.", + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:as-migration:no-cred-reuse': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration#authorization-server-migration', + behavior: + 'A single-slot OAuthClientProvider that round-trips the SDK-stamped value is protected: the previous-AS client_id is never transmitted to any endpoint of the new authorization server because the issuer stamp reads back as undefined.', + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:as-migration:no-token-reuse': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration#authorization-server-migration', + behavior: + "auth() never POSTs a refresh_token to a different authorization server's token endpoint: a token whose issuer stamp does not match the resolved AS reads back as undefined and the refresh branch is skipped.", + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:as-migration:cimd-portable': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration#authorization-server-migration', + behavior: + 'CIMD (URL-based) client_ids are portable across authorization servers: when the issuer changes, auth() re-saves the same clientMetadataUrl as the client_id at the new AS without dynamic registration.', + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-auth:as-migration:m2m-expected-issuer': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration#authorization-server-migration', + behavior: + 'ClientCredentialsProvider (and the other m2m providers) constructed with expectedIssuer refuse to send the static credential to a different authorization server: the issuer-stamped clientInformation() is discarded and auth() fails before any token request.', + transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, // Client middleware (SDK) @@ -2067,11 +2498,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 +2617,181 @@ 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, serveStdio stdio entry, 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 (which hosts the entry strict via legacy: 'reject'; stateless legacy serving is the entry's own default); 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 configured strict (legacy: 'reject') 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 in that mode (stateless legacy serving is the entry's default and must be turned off explicitly).", + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: "Runs on the entryModern arm (which hosts the entry strict via legacy: 'reject'); 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) keeps serving the full 2025-era session lifecycle alongside a strict (legacy: 'reject') createMcpHandler endpoint via explicit user-land routing on the exported isLegacyRequest predicate: 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), while envelope-claiming traffic is answered by the strict modern entry and never reaches the legacy wiring.", + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + note: 'The lifecycle is a statement about 2025-era serving kept by an existing sessionful deployment, so the requirement is bounded to the 2025-11-25 axis (the entryStateless arm label). The handler-valued legacy option was removed from createMcpHandler, so the body hosts the documented replacement composition itself — isLegacyRequest in front of the existing wiring plus a strict entry — behind an in-process fetch instead of overriding the wire() arm. It pins the routing of body-less GET and DELETE to the legacy wiring, observed at the wiring 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." + }, + + // v2 features: dual-era HTTP entry — HTTP request mechanics on the harness-hosted entry + // (entry-side siblings of the hosting:http / hosting:stateless families, which hand-host the + // server transport themselves and so never reach createMcpHandler when given an entry arm). + + 'typescript:hosting:entry:method-405': { + source: 'sdk', + behavior: + 'A non-POST HTTP method (GET, DELETE, PUT, PATCH) on a createMcpHandler endpoint is answered 405 with a JSON-RPC Method-not-allowed body on both legs: the stateless legacy fallback rejects every non-POST method, and the modern-only strict path rejects body-less non-POST traffic via the modern-only-method-not-allowed cell.', + transports: ['entryStateless', 'entryModern'], + note: 'Runs on the createMcpHandler entry arms; each non-POST method is sent through wired.fetch so the HTTP status and body are observed directly. The entry does not emit an Allow header (the per-session server transport does), so only the status and JSON-RPC error shape are pinned.' + }, + 'typescript:hosting:entry:parse-error-400': { + source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server', + behavior: + 'A POST whose body is not valid JSON is answered 400 by a createMcpHandler endpoint on both legs, with a JSON-RPC Parse-error (-32700) body: the entry classifier reads no envelope claim from a non-JSON body, so the stateless legacy fallback delegates the parse error and the modern-only strict path emits it itself.', + transports: ['entryStateless', 'entryModern'], + note: 'Runs on the createMcpHandler entry arms; the malformed body is POSTed through wired.fetch so the HTTP status and JSON-RPC error code are observed directly.' + }, + 'typescript:hosting:entry:legacy-accept-406': { + source: 'sdk', + behavior: + "A 2025-era POST whose Accept header does not allow both application/json and text/event-stream is answered 406 by a createMcpHandler endpoint's stateless legacy slot (the legacy fallback delegates to the streamable HTTP server transport, whose Accept negotiation is unchanged).", + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + note: 'Runs on the entryStateless arm and is bounded to the 2025-11-25 axis: Accept negotiation is enforced by the legacy server transport the fallback delegates to, not by the modern per-request path. The probes are POSTed through wired.fetch so the 406 is observed directly.' + }, + 'typescript:hosting:entry:legacy-content-type-415': { + source: 'sdk', + behavior: + "A 2025-era POST whose Content-Type is not application/json is answered 415 by a createMcpHandler endpoint's stateless legacy slot (the legacy fallback delegates to the streamable HTTP server transport, whose Content-Type validation is unchanged).", + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + note: 'Runs on the entryStateless arm and is bounded to the 2025-11-25 axis: Content-Type validation is enforced by the legacy server transport the fallback delegates to. The entry classifier reads the body before that delegate runs, so a body that happens to be valid JSON is still rejected on Content-Type alone.' + }, + 'typescript:hosting:entry:legacy-protocol-version-header-400': { + source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header', + behavior: + "A 2025-era POST carrying an MCP-Protocol-Version header naming an unknown revision is answered 400 by a createMcpHandler endpoint's stateless legacy slot, with the response body naming the supported version(s).", + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + note: 'Runs on the entryStateless arm and is bounded to the 2025-11-25 axis: the protocol-version header check is enforced by the legacy server transport the fallback delegates to. Header/body cross-checks on the modern path are pinned by the entry std-header rows; this row pins only that a non-modern unsupported header still surfaces as 400 through the fallback.' + }, + 'typescript:hosting:entry:legacy-protocol-version-default': { + source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header', + behavior: + "A 2025-era POST without an MCP-Protocol-Version header is served by a createMcpHandler endpoint's stateless legacy slot under the assumed default protocol version (2025-03-26): a tools/list round-trips without the header.", + transports: ['entryStateless'], + removedInSpecVersion: '2026-07-28', + note: 'Runs on the entryStateless arm and is bounded to the 2025-11-25 axis. The probe is POSTed through wired.fetch with only Accept and Content-Type headers so the default-version path is the one exercised.' + }, + 'typescript:hosting:entry:no-session-id': { + source: 'sdk', + behavior: + 'A createMcpHandler endpoint emits no Mcp-Session-Id response header on either leg: the stateless legacy fallback hosts a sessionless server transport per request, and the modern per-request path has no session at all — every recorded exchange of a connect-then-tools/call round trip carries no session header.', + transports: ['entryStateless', 'entryModern'], + note: "Runs on the createMcpHandler entry arms; asserted on the arm-recorded httpLog response clones. The entry's BYO sessionful composition is the only way to issue a session id and is pinned by typescript:hosting:entry:byo-sessionful-legacy." + }, + 'typescript:hosting:entry:ctx-http-req-headers': { + source: 'sdk', + behavior: + "A custom HTTP header set on the StreamableHTTP client transport reaches a tool handler's ctx.http.req as Fetch Headers when the server is hosted by createMcpHandler, on both legs: the stateless legacy fallback and the modern per-request path each thread the original Request through to handler context.", + transports: ['entryStateless', 'entryModern'], + note: "The body hosts createMcpHandler itself (the wire() entry arm builds the client transport without a custom-header hook) and the matrix arm selects the legacy posture and client pin: entryStateless drives a plain client through legacy: 'stateless', entryModern drives a 2026-07-28-pinned client through legacy: 'reject'." + }, + + // v2 features: dual-era HTTP entry — bearer auth composed in front of createMcpHandler + // (entry-side siblings of the hosting:auth family, which hand-hosts an Express stack and so + // never reaches createMcpHandler when given an entry arm). The SDK does not enforce endpoint + // authentication on either era — bearer/OAuth auth is deployer-composed middleware in front of + // whichever handler is mounted, and the entry passes a verified AuthInfo through unchanged. + + 'typescript:hosting:entry:auth:missing-401': { + source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#error-handling', + behavior: + 'A bearer-protected createMcpHandler deployment — a user-composed verification gate in front of handler.fetch — answers a request without an Authorization header with 401 and a WWW-Authenticate challenge on both legs, and the entry is never reached for that request (no factory call).', + transports: ['entryStateless', 'entryModern'], + note: "The body hosts createMcpHandler itself behind the documented bearer-gate composition (verify the Authorization header, then call handler.fetch(request, { authInfo })); the matrix arm selects the legacy posture and client pin. The 401/WWW-Authenticate is the gate's own response — the entry performs no token verification — and the body asserts the gate composes correctly with both serving paths." + }, + 'typescript:hosting:entry:auth:authinfo-propagates': { + source: 'sdk', + behavior: + "A verified AuthInfo handed to createMcpHandler.fetch(request, { authInfo }) reaches per-request handlers as ctx.http.authInfo unchanged on both legs, and the same AuthInfo is exposed on the factory's per-request context (McpRequestContext.authInfo) before the instance is built.", + transports: ['entryStateless', 'entryModern'], + note: 'The body hosts createMcpHandler itself behind the documented bearer-gate composition; the matrix arm selects the legacy posture and client pin. authInfo is strictly pass-through — the entry never derives it from request headers — so the cell pins delivery, not verification. The OAuth client flow that obtains the token is hosting-agnostic and is covered by the client-auth family; the dedicated client-completes-OAuth-then-negotiates-2026 journey rides the auth-package redo (M13.1) so it is targeted at the surviving auth surface.' + }, + 'typescript:hosting:entry:auth:insufficient-scope-403': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#scope-mismatch-handling', + behavior: + 'A bearer-protected createMcpHandler deployment whose gate enforces a per-operation scope (deriving the operation from the standard Mcp-Method/Mcp-Name request headers on the modern leg) answers an under-scoped request with 403 and a WWW-Authenticate insufficient_scope challenge naming the required scope, without the entry ever being reached for that request.', + transports: ['entryStateless', 'entryModern'], + note: 'The body hosts createMcpHandler behind a per-operation scoped bearer gate; the matrix arm selects the legacy posture and client pin. On the legacy leg the gate falls back to a single required scope (no Mcp-Name header). The cell pins the documented RS-side composition that the client-auth:stepup family drives from the client side.' + }, + + 'typescript:transport:stdio:dual-era-serving': { + source: 'sdk', + behavior: + 'A stdio server hosted by the connection-pinned serveStdio entry serves a plain 2025 client via initialize and an auto-negotiating client on 2026-07-28 via server/discover, each on its own connection against the same factory, 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 opens the connection.' }, 'custom-methods:server-handler:roundtrip': { source: 'sdk', @@ -2209,10 +2814,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.' @@ -2226,14 +2832,16 @@ export const REQUIREMENTS: Record = { source: 'sdk', behavior: 'ctx.mcpReq.log() inside a registered tool handler emits a notifications/message logging notification that the client receives while the call is in flight.', - 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: 'Emitted request-related, so on per-request hosting (createMcpHandler, either era) the notification rides the in-flight exchange like progress; the streamableHttpStateless arm has no per-request stream visible to the body and stays restricted.' }, 'mcpserver:context:elicit-from-handler': { source: 'sdk', behavior: "ctx.mcpReq.elicitInput() inside a tool handler sends elicitation/create to the client and resolves with the client's ElicitResult, which the handler can fold into its tool result.", transports: STATEFUL_TRANSPORTS, + removedInSpecVersion: '2026-07-28', + supersededBy: 'elicitation:mrtr:form:basic', 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.' }, 'mcpserver:context:sampling-from-handler': { @@ -2241,6 +2849,8 @@ export const REQUIREMENTS: Record = { behavior: "ctx.mcpReq.requestSampling() inside a tool handler sends sampling/createMessage to the client and resolves with the client's CreateMessageResult.", transports: STATEFUL_TRANSPORTS, + removedInSpecVersion: '2026-07-28', + supersededBy: 'sampling:mrtr:create:basic', 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.' }, 'hosting:context:web-request-headers': { @@ -2316,11 +2926,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 +2987,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', @@ -2443,6 +3062,64 @@ export const REQUIREMENTS: Record = { transports: ['streamableHttp'], note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, + // SEP-2243 request-metadata headers (protocol revision 2026-07-28) + 'sep-2243:param-header:roundtrip': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#custom-headers-from-tool-parameters', + behavior: + 'A tools/call to a tool whose inputSchema declares an x-mcp-header property carries the corresponding Mcp-Param-{Name} HTTP header on the wire, encoded per the SEP-2243 value-encoding rules, and the call completes successfully against a validating server.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'Runs on the entryModern arm; the Mcp-Param-{Name} header is asserted on the arm-recorded HTTP request headers and the encoded value is checked against the SEP-2243 codec.' + }, + 'sep-2243:std-header:mismatch-rejected': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#standard-request-headers', + behavior: + 'A 2026-07-28 request whose Mcp-Method header disagrees with the JSON-RPC method in the body is rejected by the createMcpHandler entry with HTTP 400 carrying a JSON-RPC error with the SEP-2243 HeaderMismatch code.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'Runs on the entryModern arm; the body POSTs a raw envelope-carrying tools/call with an Mcp-Method: tools/list header through wired.fetch and asserts the 400 status and the HeaderMismatch error code on the response bytes.' + }, + // Multi round-trip requests (SEP-2322, protocol revision 2026-07-28) + 'typescript:mrtr:tools-call:write-once-roundtrip': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr', + behavior: + 'A write-once tool that returns inputRequired() on a 2026-07-28 connection is fulfilled by the client auto-fulfilment driver: the registered elicitation handler answers the embedded request, and the original call is retried with a fresh request id, a byte-exact requestState echo, and the collected inputResponses, completing as a plain CallToolResult.', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Runs on the entryModern arm; the input_required wire shape, the fresh request id, and the byte-exact requestState echo are asserted on the arm-recorded HTTP exchanges.' + }, + 'typescript:mrtr:push-api:loud-fail-2026': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr', + behavior: + 'The push-style server→client APIs (e.g. ctx.mcpReq.elicitInput) on a 2026-07-28 request fail with a typed local error before any wire traffic; in a tool handler the error surfaces as an isError result whose text steers to inputRequired(...).', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Runs on the entryModern arm; the absence of any server→client request on the wire is asserted on the arm-recorded HTTP bytes.' + }, + 'typescript:mrtr:url-elicitation:no-32042-on-2026': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr', + behavior: + 'URL-mode elicitation rides the multi-round-trip flow on the 2026-07-28 era: a tool handler that returns inputRequired.elicitUrl(...) embeds a URL-mode elicitation/create in an input_required result (capability-gated by -32021 on elicitation.url), the registered elicitation handler fulfils it, the retried call completes, and the urlElicitationRequired error code (-32042) never appears on the wire.', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + supersedes: ['mcpserver:tool:url-elicitation-error', 'elicitation:url:required-error'], + note: 'Runs on the entryModern arm; the input_required wire shape and the absence of -32042 anywhere in the exchange are asserted on the arm-recorded HTTP bytes.' + }, + 'typescript:mrtr:rounds-cap': { + source: 'sdk', + behavior: + 'The client auto-fulfilment driver is bounded: when a server keeps answering input_required, the call fails with the typed InputRequiredRoundsExceeded error (carrying the last input_required payload) once the configurable inputRequired.maxRounds cap is exhausted, instead of looping forever.', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Runs on the entryModern arm so the round count can be asserted directly on the arm-recorded HTTP exchanges.' + }, + 'typescript:mrtr:legacy-32042-freeze': { + source: 'sdk', + behavior: + 'On 2025-era serving, a UrlElicitationRequiredError thrown by a tool handler still reaches the client as the exact urlElicitationRequired protocol error: code -32042 with data.elicitations carrying the URL-mode elicitation params, byte-identical to the pre-multi-round-trip behavior.', + removedInSpecVersion: '2026-07-28', + note: 'Bounded to the 2025-11-25 axis: this is the freeze cell pinning that the 2026-07-28 era guard leaves the deployed -32042 surface untouched on legacy serving.' + }, // Legacy SSE 'transport:sse:server-transport': { source: 'sdk', @@ -2450,6 +3127,204 @@ export const REQUIREMENTS: Record = { 'The SDK provides a server-side legacy HTTP+SSE transport so existing SSE deployments can be hosted on SDK components alone.', transports: ['sse'], note: 'This asserts the availability of the server half of the legacy SSE transport (SSEServerTransport from @modelcontextprotocol/server-legacy/sse); the matrix transport arg is ignored, so it runs as a single sse-labelled cell.' + }, + 'subscriptions:listen:ack-first-stamped': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/subscriptions#acknowledgment', + behavior: + "notifications/subscriptions/acknowledged is the first message on a subscriptions/listen stream and carries the listen request's JSON-RPC id verbatim under the io.modelcontextprotocol/subscriptionId _meta key, plus the honored subset of the requested filter.", + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Hosted by the test body via createMcpHandler so it can publish via handler.notify.' + }, + 'subscriptions:listen:per-stream-filter': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/subscriptions#notification-filter', + behavior: + 'A subscriptions/listen stream receives only the notification types its filter explicitly requested; an un-requested type is provably never delivered. Change notifications dispatch to the existing setNotificationHandler registrations.', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Hosted by the test body via createMcpHandler so it can publish via handler.notify.' + }, + 'subscriptions:listen:honored-filter-narrows-to-advertised': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/subscriptions#acknowledgment', + behavior: + "The acknowledged filter on a subscriptions/listen stream is the requested set narrowed against the server's declared listChanged/subscribe capability bits — a requested type the server does not advertise is dropped from honoredFilter and is never delivered.", + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Hosted by the test body via createMcpHandler so it can publish via handler.notify. A stdio e2e of the modern listen path is not yet feasible without harness changes (the e2e stdio arms wire the standard child-process StdioServerTransport, not the serveStdio entry); stdio narrowing is covered at unit level in serveStdioListen.test.ts.' + }, + 'subscriptions:listen:capacity-guard': { + source: 'sdk', + behavior: + "A subscriptions/listen request is refused with -32603 'Subscription limit reached' (in-band on HTTP 200, before the ack) when the configured maxSubscriptions is reached.", + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Hosted by the test body via createMcpHandler with maxSubscriptions: 1.' + }, + 'subscriptions:listen:graceful-close': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/subscriptions#graceful-closure', + behavior: + "On a server-side graceful close, the server emits the empty subscriptions/listen JSON-RPC result (the SubscriptionsListenResult — _meta carries the subscriptionId stamp) before closing the stream; the client surfaces this on McpSubscription.closed as 'graceful' (distinct from a transport drop, which surfaces as 'remote').", + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Hosted by the test body via createMcpHandler so it can call handler.close(). The stdio path is covered at unit level in serveStdioListen.test.ts.' + }, + 'typescript:subscriptions:listChanged-auto-open-modern': { + source: 'sdk', + behavior: + 'ClientOptions.listChanged auto-opens a subscriptions/listen stream on a modern connection — the filter is the intersection of the configured sub-options and the server-advertised listChanged capabilities (auto-open is skipped and autoOpenedSubscription stays undefined when the intersection is empty) — so the configured handlers fire on every published change. The auto-opened subscription is exposed for close.', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Hosted by the test body via createMcpHandler so it can publish via handler.notify.' + }, + 'typescript:subscriptions:listen:legacy-era-steer': { + source: 'sdk', + behavior: + 'On a 2025-era connection, Client.listen() throws a typed MethodNotSupportedByProtocolVersion error steering to resources/subscribe and ClientOptions.listChanged before any wire write (no transparent shim).', + removedInSpecVersion: '2026-07-28', + note: 'Runs on the 2025-era arms; the entryModern arm is bound out by the removedInSpecVersion.' + }, + + // 2026-era siblings of the push-style sampling/elicitation/roots round-trips: the 2025-shape + // bodies push a server→client request; on the 2026-07-28 era the same spec behavior rides the + // multi-round-trip flow (a handler returns inputRequired() and the client auto-fulfilment driver + // dispatches the embedded request to the locally registered handler). Each row supersedes the + // 2025-shape sibling(s) it covers. + + 'sampling:mrtr:create:basic': { + source: 'https://modelcontextprotocol.io/specification/draft/client/sampling#creating-messages', + behavior: + "An embedded sampling/createMessage request returned via inputRequired() from a tool handler is fulfilled by the client's sampling handler, and the handler's result (role, content, model, stopReason) reaches the retried tool handler in inputResponses.", + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + supersedes: ['sampling:create:basic', 'tools:call:sampling-roundtrip', 'mcpserver:context:sampling-from-handler'], + note: 'Runs on the entryModern arm; the 2026 path for a server handler to obtain a sampling completion is inputRequired.createMessage(...) — the push-style server.createMessage / ctx.mcpReq.requestSampling APIs are era-gated to fail on this revision.' + }, + 'sampling:mrtr:create:model-preferences': { + source: 'https://modelcontextprotocol.io/specification/draft/client/sampling#model-preferences', + behavior: + 'The model preferences supplied in an embedded sampling/createMessage request (hints and the cost, speed, and intelligence priorities) reach the client sampling handler intact.', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + supersedes: ['sampling:create:model-preferences'], + note: 'Runs on the entryModern arm; the embedded request travels in an input_required result and the client driver dispatches it to the registered handler.' + }, + 'sampling:mrtr:create:system-prompt': { + source: 'https://modelcontextprotocol.io/specification/draft/client/sampling#creating-messages', + behavior: 'The system prompt supplied in an embedded sampling/createMessage request reaches the client sampling handler intact.', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + supersedes: ['sampling:create:system-prompt'], + note: 'Runs on the entryModern arm; the embedded request travels in an input_required result and the client driver dispatches it to the registered handler.' + }, + 'sampling:mrtr:create:include-context': { + source: 'https://modelcontextprotocol.io/specification/draft/client/sampling#capabilities', + behavior: + 'The includeContext value supplied in an embedded sampling/createMessage request reaches the client sampling handler intact.', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + supersedes: ['sampling:create:include-context'], + note: 'Runs on the entryModern arm; the embedded request travels in an input_required result and the client driver dispatches it to the registered handler.' + }, + 'elicitation:mrtr:form:basic': { + source: 'https://modelcontextprotocol.io/specification/draft/client/elicitation#form-mode-elicitation-requests', + behavior: + "An embedded form-mode elicitation/create request returned via inputRequired() from a tool handler delivers the message and requested schema to the client's elicitation handler exactly as sent, and an accept response carrying the user's content reaches the retried tool handler in inputResponses.", + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + supersedes: [ + 'elicitation:form:basic', + 'tools:call:elicitation-roundtrip', + 'mcpserver:context:elicit-from-handler', + 'elicitation:form:action:accept' + ], + note: 'Runs on the entryModern arm; the 2026 path for a server handler to obtain elicited input is inputRequired.elicit(...) — the push-style server.elicitInput / ctx.mcpReq.elicitInput APIs are era-gated to fail on this revision.' + }, + 'elicitation:mrtr:form:action:decline': { + source: 'https://modelcontextprotocol.io/specification/draft/client/elicitation#response-actions', + behavior: + "An embedded form-mode elicitation answered with action 'decline' reaches the retried handler in inputResponses with no content; acceptedContent() returns undefined for it.", + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + supersedes: ['elicitation:form:action:decline'], + note: 'Runs on the entryModern arm; the embedded request travels in an input_required result and the client driver dispatches it to the registered handler.' + }, + 'elicitation:mrtr:form:action:cancel': { + source: 'https://modelcontextprotocol.io/specification/draft/client/elicitation#response-actions', + behavior: + "An embedded form-mode elicitation answered with action 'cancel' reaches the retried handler in inputResponses with no content; acceptedContent() returns undefined for it.", + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + supersedes: ['elicitation:form:action:cancel'], + note: 'Runs on the entryModern arm; the embedded request travels in an input_required result and the client driver dispatches it to the registered handler.' + }, + 'elicitation:mrtr:form:schema:primitives': { + source: 'https://modelcontextprotocol.io/specification/draft/client/elicitation#requested-schema', + behavior: + 'Requested-schema fields on an embedded form-mode elicitation may be string (with format), number or integer, or boolean; they reach the client handler intact.', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + supersedes: ['elicitation:form:schema:primitives'], + note: 'Runs on the entryModern arm; the embedded request travels in an input_required result and the client driver dispatches it to the registered handler.' + }, + 'roots:mrtr:list:basic': { + source: 'https://modelcontextprotocol.io/specification/draft/client/roots#listing-roots', + behavior: + "An embedded roots/list request returned via inputRequired() from a tool handler is fulfilled by the client's roots handler, and the returned roots (uri, name) reach the retried tool handler in inputResponses.", + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + supersedes: ['roots:list:basic'], + note: 'Runs on the entryModern arm; the 2026 path for a server handler to obtain the client roots is inputRequired.listRoots() — the push-style server.listRoots() API is era-gated to fail on this revision.' + }, + 'roots:mrtr:list:empty': { + source: 'https://modelcontextprotocol.io/specification/draft/client/roots#listing-roots', + behavior: + 'An empty roots list returned by the client roots handler for an embedded roots/list request reaches the retried tool handler as such.', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + supersedes: ['roots:list:empty'], + note: 'Runs on the entryModern arm; the embedded request travels in an input_required result and the client driver dispatches it to the registered handler.' + }, + + // 2026-era siblings of the captured-instance list_changed publish rows: the 2025-shape bodies + // publish by mutating the connected server instance; on the 2026-07-28 era the publication path + // is handler.notify.* and delivery rides a subscriptions/listen stream. Each row supersedes the + // 2025-shape sibling it covers. + + 'tools:listen:list-changed': { + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#list-changed-notification', + behavior: + "A notifications/tools/list_changed published via handler.notify.toolsChanged() reaches a client whose subscriptions/listen stream requested toolsListChanged, and is dispatched to the client's registered notification handler.", + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + supersedes: ['tools:list-changed'], + note: 'Hosted by the test body via createMcpHandler so it can publish via handler.notify; the 2026 publication path is the entry-level notifier, not mutation of a captured server instance.' + }, + 'resources:listen:list-changed': { + source: 'https://modelcontextprotocol.io/specification/draft/server/resources#list-changed-notification', + behavior: + "A notifications/resources/list_changed published via handler.notify.resourcesChanged() reaches a client whose subscriptions/listen stream requested resourcesListChanged, and is dispatched to the client's registered notification handler.", + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + supersedes: ['resources:list-changed'], + note: 'Hosted by the test body via createMcpHandler so it can publish via handler.notify; the 2026 publication path is the entry-level notifier, not mutation of a captured server instance.' + }, + 'prompts:listen:list-changed': { + source: 'https://modelcontextprotocol.io/specification/draft/server/prompts#list-changed-notification', + behavior: + "A notifications/prompts/list_changed published via handler.notify.promptsChanged() reaches a client whose subscriptions/listen stream requested promptsListChanged, and is dispatched to the client's registered notification handler.", + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + supersedes: ['prompts:list-changed'], + note: 'Hosted by the test body via createMcpHandler so it can publish via handler.notify; the 2026 publication path is the entry-level notifier, not mutation of a captured server instance.' + }, + 'client:listen:auto-refresh': { + source: 'sdk', + behavior: + 'A client configured with listChanged auto-refresh, on a modern connection, opens a subscriptions/listen stream and on each published change re-fetches the corresponding list and delivers the fresh result to its callback.', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + supersedes: ['client:list-changed:auto-refresh'], + note: 'Hosted by the test body via createMcpHandler so it can publish via handler.notify; the auto-opened subscription is the modern delivery path for ClientOptions.listChanged.' } } satisfies Record; diff --git a/test/e2e/scenarios/client-auth.test.ts b/test/e2e/scenarios/client-auth.test.ts index 0d19a40cb8..b3e94a0789 100644 --- a/test/e2e/scenarios/client-auth.test.ts +++ b/test/e2e/scenarios/client-auth.test.ts @@ -8,20 +8,29 @@ import { createHash, generateKeyPairSync, sign } from 'node:crypto'; -import type { AuthProvider, OAuthClientProvider } from '@modelcontextprotocol/client'; +import type { AuthProvider, OAuthClientProvider, OAuthDiscoveryState } from '@modelcontextprotocol/client'; import { applyMiddlewares, auth, + AuthorizationServerMismatchError, Client, ClientCredentialsProvider, + computeScopeUnion, createMiddleware, discoverAuthorizationServerMetadata, discoverOAuthProtectedResourceMetadata, exchangeAuthorization, + InsecureTokenEndpointError, + InsufficientScopeError, + isStrictScopeSuperset, + IssuerMismatchError, OAuthError, OAuthErrorCode, PrivateKeyJwtProvider, refreshAuthorization, + registerClient, + RegistrationRejectedError, + resolveClientMetadata, SdkError, SSEClientTransport, SseError, @@ -29,6 +38,7 @@ import { StaticPrivateKeyJwtProvider, StreamableHTTPClientTransport, UnauthorizedError, + validateAuthorizationResponseIssuer, withLogging, withOAuth } from '@modelcontextprotocol/client'; @@ -36,14 +46,17 @@ import type { AuthorizationServerMetadata, OAuthClientInformationFull, OAuthClientInformationMixed, - OAuthTokens + OAuthClientMetadata, + OAuthTokens, + StoredOAuthClientInformation, + StoredOAuthTokens } from '@modelcontextprotocol/server'; import { LATEST_PROTOCOL_VERSION, McpServer } from '@modelcontextprotocol/server'; import { importSPKI, jwtVerify } from 'jose'; import { expect, vi } from 'vitest'; import { z } from 'zod/v4'; -import { hostPerSession } from '../helpers/index.js'; +import { defined, hostPerSession } from '../helpers/index.js'; import { verifies } from '../helpers/verifies.js'; import type { TestArgs } from '../types.js'; @@ -51,16 +64,11 @@ const ISSUER = 'https://auth.example.com'; const MCP_URL = 'http://in-process/mcp'; const RESOURCE = 'http://in-process/mcp'; -// Narrows indexed-access results that the surrounding count assertions have already proven to exist. -function defined(value: T | undefined, label: string): T { - if (value === undefined) throw new Error(`Expected ${label} to be defined`); - return value; -} - interface MockASConfig { tokenResponses?: Array>; tokenErrorResponses?: Array<{ error: string; error_description?: string }>; registerResponse?: Partial; + registerErrorResponse?: { status: number; error: string; error_description?: string }; asMetadata?: Partial; prmMetadata?: Record; noPRMDiscovery?: boolean; @@ -155,6 +163,10 @@ function createMockAuthorizationServer(config: MockASConfig = {}) { if (path === '/register' && req.method === 'POST') { const body = z.record(z.string(), z.unknown()).parse(await req.json()); registerCalls.push({ body }); + if (config.registerErrorResponse) { + const { status, ...err } = config.registerErrorResponse; + return Response.json(err, { status, headers: { 'Content-Type': 'application/json' } }); + } // RFC 7591: the registration response echoes the submitted metadata plus issued credentials. const response = { ...body, @@ -190,6 +202,7 @@ class RecordingOAuthClientProvider implements OAuthClientProvider { tokens?: OAuthTokens; clientInformation?: OAuthClientInformationMixed; clientMetadataUrl?: string; + clientMetadata?: Partial; } = {} ) { if (initial.tokens) this.saved.tokens = initial.tokens; @@ -204,10 +217,11 @@ class RecordingOAuthClientProvider implements OAuthClientProvider { return this.initial.clientMetadataUrl; } - get clientMetadata() { + get clientMetadata(): OAuthClientMetadata { return { client_name: 'Test Client', - redirect_uris: [this.redirectUrl] + redirect_uris: [this.redirectUrl], + ...this.initial.clientMetadata }; } @@ -417,12 +431,13 @@ verifies('client-auth:403-scope-upgrade', async (_args: TestArgs) => { await interactiveClient.close(); } - // Phase 2: when the upscoped token is still rejected with the same header, the transport stops instead of looping. + // Phase 2: when the upscoped token is still rejected, the transport stops at the per-send retry cap instead of looping. + // The token here already covers the challenged scope, so refresh (not a fresh authorization) is used. const refreshAs = createMockAuthorizationServer({ - tokenResponses: [{ access_token: 'upscoped-access-token', token_type: 'Bearer' }] + tokenResponses: [{ access_token: 'upscoped-access-token', token_type: 'Bearer', scope: UPGRADED_SCOPE }] }); const refreshProvider = new RecordingOAuthClientProvider({ - tokens: { access_token: 'narrow-scope-token', token_type: 'Bearer', refresh_token: 'narrow-refresh-token' }, + tokens: { access_token: 'narrow-scope-token', token_type: 'Bearer', refresh_token: 'narrow-refresh-token', scope: UPGRADED_SCOPE }, clientInformation: { client_id: 'pre-registered-client' } }); @@ -445,7 +460,7 @@ verifies('client-auth:403-scope-upgrade', async (_args: TestArgs) => { try { const connectPromise = refreshClient.connect(refreshTransport); await expect(connectPromise).rejects.toBeInstanceOf(SdkError); - await expect(connectPromise).rejects.toThrow(/403 after trying upscoping/); + await expect(connectPromise).rejects.toThrow(/403 insufficient_scope after step-up re-authorization/); expect(refreshAs.tokenCalls).toHaveLength(1); expect(defined(refreshAs.tokenCalls[0], 'token call').body.get('grant_type')).toBe('refresh_token'); @@ -455,6 +470,194 @@ verifies('client-auth:403-scope-upgrade', async (_args: TestArgs) => { } }); +verifies('client-auth:stepup:scope-union', async (_args: TestArgs) => { + // computeScopeUnion is a deliberate public export. + expect(computeScopeUnion('files:read openid', 'files:write')).toBe('files:read openid files:write'); + expect(computeScopeUnion('admin', 'read')).toBe('admin read'); // no hierarchical collapse + + // The transport requests the union of its previously-requested scope and the + // newly-challenged scope on step-up. + const PREVIOUS_SCOPE = 'files:read openid'; + const CHALLENGED_SCOPE = 'files:write'; + const as = createMockAuthorizationServer(); + const provider = new RecordingOAuthClientProvider({ + tokens: { access_token: 'read-token', token_type: 'Bearer', scope: PREVIOUS_SCOPE }, + clientInformation: { client_id: 'pre-registered-client' } + }); + const combinedFetch = async (url: URL | string, init?: RequestInit): Promise => { + const urlObj = typeof url === 'string' ? new URL(url) : url; + if (urlObj.origin === ISSUER || urlObj.pathname.includes('/.well-known/')) { + return as.handleRequest(new Request(url, init)); + } + return new Response(null, { + status: 403, + headers: { 'WWW-Authenticate': `Bearer error="insufficient_scope", scope="${CHALLENGED_SCOPE}"` } + }); + }; + + const client = new Client({ name: 'c', version: '0' }); + const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch }); + try { + await expect(client.connect(transport)).rejects.toThrow(UnauthorizedError); + expect(provider.redirectedTo).toHaveLength(1); + const redirect = defined(provider.redirectedTo[0], 'authorize URL'); + expect(redirect.searchParams.get('scope')).toBe('files:read openid files:write'); + } finally { + await client.close(); + } +}); + +verifies(['client-auth:stepup:retry-cap', 'client-auth:stepup:refresh-bypass-on-superset'], async (_args: TestArgs) => { + // Part A — superset bypass: token granted "files:read"; challenged scope adds + // "files:write". Union strictly exceeds the token's grant → auth() forces a + // fresh authorization request (no refresh-token POST observed). + { + const as = createMockAuthorizationServer(); + const provider = new RecordingOAuthClientProvider({ + tokens: { access_token: 't', token_type: 'Bearer', refresh_token: 'rt', scope: 'files:read' }, + clientInformation: { client_id: 'pre-registered-client' } + }); + const combinedFetch = async (url: URL | string, init?: RequestInit): Promise => { + const urlObj = typeof url === 'string' ? new URL(url) : url; + if (urlObj.origin === ISSUER || urlObj.pathname.includes('/.well-known/')) { + return as.handleRequest(new Request(url, init)); + } + return new Response(null, { + status: 403, + headers: { 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="files:write"' } + }); + }; + const client = new Client({ name: 'c', version: '0' }); + const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch }); + try { + await expect(client.connect(transport)).rejects.toThrow(UnauthorizedError); + // Refresh was bypassed: no token-endpoint POST; the fresh authorize + // request carries the union scope. + expect(as.tokenCalls).toHaveLength(0); + expect(provider.redirectedTo).toHaveLength(1); + expect(defined(provider.redirectedTo[0], 'authorize URL').searchParams.get('scope')).toBe('files:read files:write'); + expect(isStrictScopeSuperset('files:read files:write', 'files:read')).toBe(true); + } finally { + await client.close(); + } + } + + // Part B — refresh used + retry cap: token already covers the challenged + // scope (server is misconfigured / hierarchical). Union is NOT a strict + // superset → refresh is used. Server keeps 403'ing → per-send retry cap + // (default 1) stops the loop after exactly one step-up. + { + const as = createMockAuthorizationServer({ + tokenResponses: [{ access_token: 't2', token_type: 'Bearer', scope: 'files:read files:write' }] + }); + const provider = new RecordingOAuthClientProvider({ + tokens: { access_token: 't', token_type: 'Bearer', refresh_token: 'rt', scope: 'files:read files:write' }, + clientInformation: { client_id: 'pre-registered-client' } + }); + const mcpPosts: string[] = []; + const combinedFetch = async (url: URL | string, init?: RequestInit): Promise => { + const urlObj = typeof url === 'string' ? new URL(url) : url; + if (urlObj.origin === ISSUER || urlObj.pathname.includes('/.well-known/')) { + return as.handleRequest(new Request(url, init)); + } + mcpPosts.push(urlObj.pathname); + return new Response(null, { + status: 403, + headers: { 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="files:write"' } + }); + }; + const client = new Client({ name: 'c', version: '0' }); + const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch }); + try { + const connectPromise = client.connect(transport); + await expect(connectPromise).rejects.toBeInstanceOf(SdkError); + await expect(connectPromise).rejects.toThrow(/retry limit 1 reached/); + expect(as.tokenCalls).toHaveLength(1); + expect(defined(as.tokenCalls[0], 'token call').body.get('grant_type')).toBe('refresh_token'); + expect(provider.redirectedTo).toHaveLength(0); + expect(mcpPosts).toHaveLength(2); + expect(isStrictScopeSuperset('files:read files:write', 'files:read files:write')).toBe(false); + } finally { + await client.close(); + } + } +}); + +verifies('client-auth:stepup:throw-mode', async (_args: TestArgs) => { + const as = createMockAuthorizationServer(); + const provider = new RecordingOAuthClientProvider({ + tokens: { access_token: 't', token_type: 'Bearer' }, + clientInformation: { client_id: 'pre-registered-client' } + }); + const combinedFetch = async (url: URL | string, init?: RequestInit): Promise => { + const urlObj = typeof url === 'string' ? new URL(url) : url; + if (urlObj.origin === ISSUER || urlObj.pathname.includes('/.well-known/')) { + return as.handleRequest(new Request(url, init)); + } + return new Response(null, { + status: 403, + headers: { + 'WWW-Authenticate': `Bearer error="insufficient_scope", scope="files:write", resource_metadata="${MCP_URL}/.well-known/oauth-protected-resource", error_description="write permission required"` + } + }); + }; + + const client = new Client({ name: 'c', version: '0' }); + const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { + authProvider: provider, + fetch: combinedFetch, + onInsufficientScope: 'throw' + }); + try { + const connectPromise = client.connect(transport); + await expect(connectPromise).rejects.toBeInstanceOf(InsufficientScopeError); + await expect(connectPromise).rejects.toMatchObject({ + requiredScope: 'files:write', + errorDescription: 'write permission required', + resourceMetadataUrl: new URL(`${MCP_URL}/.well-known/oauth-protected-resource`) + }); + // No re-authorization was attempted. + expect(as.tokenCalls).toHaveLength(0); + expect(as.discoveryCalls).toHaveLength(0); + expect(provider.redirectedTo).toHaveLength(0); + } finally { + await client.close(); + } +}); + +verifies('client-auth:stepup:get-stream-403', async (_args: TestArgs) => { + // The GET listen-stream open path applies the same step-up handling. + // We assert via 'throw' mode (parity with the POST path is the same private + // helper) so the test observes the GET branch reaching the step-up gate. + const provider = new RecordingOAuthClientProvider({ + tokens: { access_token: 't', token_type: 'Bearer' }, + clientInformation: { client_id: 'pre-registered-client' } + }); + const seenMethods: string[] = []; + const combinedFetch = async (url: URL | string, init?: RequestInit): Promise => { + seenMethods.push(init?.method ?? 'GET'); + return new Response(null, { + status: 403, + headers: { 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="listen"' } + }); + }; + + const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { + authProvider: provider, + fetch: combinedFetch, + onInsufficientScope: 'throw' + }); + await transport.start(); + try { + const resumePromise = transport.resumeStream('last-event-42'); + await expect(resumePromise).rejects.toBeInstanceOf(InsufficientScopeError); + await expect(resumePromise).rejects.toMatchObject({ requiredScope: 'listen' }); + expect(seenMethods).toEqual(['GET']); + } finally { + await transport.close(); + } +}); + verifies('client-auth:as-metadata-discovery:priority-order', async (_args: TestArgs) => { const oauthMetadata: AuthorizationServerMetadata = { issuer: ISSUER, @@ -519,7 +722,11 @@ verifies('client-auth:as-metadata-discovery:issuer-validation', async (_args: Te const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch }); try { - await expect(client.connect(transport)).rejects.toThrow(/issuer/i); + const err: unknown = await client.connect(transport).catch((error: unknown) => error); + expect(err).toBeInstanceOf(IssuerMismatchError); + expect((err as IssuerMismatchError).kind).toBe('metadata'); + // Intentionally not an OAuthError — the auth() retry block must not swallow it. + expect(err).not.toBeInstanceOf(OAuthError); // The mismatched metadata is rejected before registering, redirecting the user, or requesting tokens. expect(as.registerCalls).toHaveLength(0); @@ -531,6 +738,152 @@ verifies('client-auth:as-metadata-discovery:issuer-validation', async (_args: Te } }); +/** + * Runs the redirect leg of the OAuth flow against a mock AS configured with `asMetadata`, then + * calls `transport.finishAuth(...)`. When `callback` is a string (or undefined) it is passed as + * `finishAuth('granted-code', iss)`; when it is a `URLSearchParams` it is passed verbatim to the + * overload. Returns the thrown error (or undefined on success) and the recorded token-endpoint + * calls so the caller can assert whether the code went on the wire. + */ +async function runFinishAuthScenario(asMetadata: Partial, callback: string | undefined | URLSearchParams) { + const as = createMockAuthorizationServer({ asMetadata, tokenResponses: [{ access_token: 'iss-flow-token', token_type: 'Bearer' }] }); + const provider = new RecordingOAuthClientProvider(); + const mcpHost = createAuthenticatedHost('iss-flow-token'); + const combinedFetch = createCombinedFetch({ as, mcpHost, validToken: 'iss-flow-token' }); + + const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch }); + const client = new Client({ name: 'c', version: '0' }); + try { + // First connect → 401 → discovery → REDIRECT. + await expect(client.connect(transport)).rejects.toThrow(UnauthorizedError); + expect(provider.redirectedTo).toHaveLength(1); + + let thrown: unknown; + try { + await (callback instanceof URLSearchParams ? transport.finishAuth(callback) : transport.finishAuth('granted-code', callback)); + } catch (error) { + thrown = error; + } + return { thrown, tokenCalls: as.tokenCalls, provider }; + } finally { + await client.close(); + await mcpHost.close(); + } +} + +verifies('client-auth:iss:match', async (_args: TestArgs) => { + const { thrown, tokenCalls, provider } = await runFinishAuthScenario({ authorization_response_iss_parameter_supported: true }, ISSUER); + expect(thrown).toBeUndefined(); + expect(tokenCalls).toHaveLength(1); + expect(defined(tokenCalls[0], 'token call').body.get('code')).toBe('granted-code'); + expect(provider.saved.tokens?.access_token).toBe('iss-flow-token'); +}); + +verifies('client-auth:iss:mismatch-reject', async (_args: TestArgs) => { + const { thrown, tokenCalls } = await runFinishAuthScenario( + { authorization_response_iss_parameter_supported: true }, + 'https://attacker.example.com' + ); + expect(thrown).toBeInstanceOf(IssuerMismatchError); + expect((thrown as IssuerMismatchError).kind).toBe('authorization_response'); + expect((thrown as IssuerMismatchError).expected).toBe(ISSUER); + // The authorization code never reaches a token endpoint. + expect(tokenCalls).toHaveLength(0); +}); + +verifies('client-auth:iss:supported-missing-reject', async (_args: TestArgs) => { + const { thrown, tokenCalls } = await runFinishAuthScenario({ authorization_response_iss_parameter_supported: true }, undefined); + expect(thrown).toBeInstanceOf(IssuerMismatchError); + expect((thrown as IssuerMismatchError).received).toBeUndefined(); + expect(tokenCalls).toHaveLength(0); +}); + +verifies('client-auth:iss:unadvertised-proceed', async (_args: TestArgs) => { + // Row 4: not advertised + iss absent → the exchange proceeds. Also covers row 3 (not advertised + // + iss present → still compared) by additionally asserting the same scenario rejects a wrong iss. + const proceed = await runFinishAuthScenario({}, undefined); + expect(proceed.thrown).toBeUndefined(); + expect(proceed.tokenCalls).toHaveLength(1); + + const reject = await runFinishAuthScenario({}, 'https://attacker.example.com'); + expect(reject.thrown).toBeInstanceOf(IssuerMismatchError); + expect(reject.tokenCalls).toHaveLength(0); +}); + +verifies('client-auth:iss:no-normalize', async (_args: TestArgs) => { + // Each value is URL-equivalent to ISSUER under RFC 3986 §6.2.2-6.2.3 normalization, but + // RFC 9207 mandates simple string comparison — every one MUST be rejected. + for (const iss of [ISSUER.toUpperCase(), `${ISSUER}/`, `${ISSUER}:443`, ISSUER.replace('https', 'HTTPS')]) { + expect(() => validateAuthorizationResponseIssuer({ iss, expectedIssuer: ISSUER, issParameterSupported: true })).toThrow( + IssuerMismatchError + ); + } + + // And end-to-end through finishAuth(): a trailing-slash difference is a real reject. + const { thrown, tokenCalls } = await runFinishAuthScenario({ authorization_response_iss_parameter_supported: true }, `${ISSUER}/`); + expect(thrown).toBeInstanceOf(IssuerMismatchError); + expect(tokenCalls).toHaveLength(0); +}); + +verifies('client-auth:iss:opt-out', async (_args: TestArgs) => { + // skipIssuerMetadataValidation suppresses AU-02 (metadata echo): mismatched-issuer metadata is accepted. + const as = createMockAuthorizationServer({ asMetadata: { issuer: 'https://misconfigured.example.com' } }); + const fetchFn = (url: URL | string, init?: RequestInit) => as.handleRequest(new Request(url, init)); + const provider = new RecordingOAuthClientProvider(); + await auth(provider, { serverUrl: MCP_URL, skipIssuerMetadataValidation: true, fetchFn }); + expect(provider.redirectedTo).toHaveLength(1); + + // It does NOT suppress AU-01 (callback iss): a mismatched iss is still rejected before token exchange. + await expect( + auth(provider, { + serverUrl: MCP_URL, + authorizationCode: 'granted-code', + iss: 'https://attacker.example.com', + skipIssuerMetadataValidation: true, + fetchFn + }) + ).rejects.toThrow(IssuerMismatchError); + expect(as.tokenCalls).toHaveLength(0); +}); + +verifies('client-auth:finishauth:urlsearchparams-sanitizes', async (_args: TestArgs) => { + const ATTACKER_TEXT = 'ATTACKER_CONTROLLED_DO_NOT_DISPLAY'; + const ATTACKER_URI = 'https://attacker.example.com/phish'; + + // Mismatched-iss callback that ALSO carries error/error_description/error_uri — a mix-up + // attacker controls all of these. The overload must throw IssuerMismatchError before reading + // them, so none of the attacker text appears on the thrown error. + const mixed = await runFinishAuthScenario( + { authorization_response_iss_parameter_supported: true }, + new URLSearchParams({ + code: 'granted-code', + state: 'state-123', + iss: 'https://attacker.example.com', + error: 'server_error', + error_description: ATTACKER_TEXT, + error_uri: ATTACKER_URI + }) + ); + expect(mixed.thrown).toBeInstanceOf(IssuerMismatchError); + const err = mixed.thrown as IssuerMismatchError; + expect(err.kind).toBe('authorization_response'); + expect(err.message).not.toContain(ATTACKER_TEXT); + expect(err.message).not.toContain(ATTACKER_URI); + expect(JSON.stringify(err)).not.toContain(ATTACKER_TEXT); + // The poisoned code never reached a token endpoint. + expect(mixed.tokenCalls).toHaveLength(0); + + // Happy path: matching iss → SDK extracts code and redeems it. + const ok = await runFinishAuthScenario( + { authorization_response_iss_parameter_supported: true }, + new URLSearchParams({ code: 'granted-code', state: 'state-123', iss: ISSUER }) + ); + expect(ok.thrown).toBeUndefined(); + expect(ok.tokenCalls).toHaveLength(1); + expect(defined(ok.tokenCalls[0], 'token call').body.get('code')).toBe('granted-code'); + expect(ok.provider.saved.tokens?.access_token).toBe('iss-flow-token'); +}); + verifies('client-auth:bearer-header:every-request', async (_args: TestArgs) => { const validToken = 'bearer-test-token'; const provider = new RecordingOAuthClientProvider({ @@ -706,9 +1059,11 @@ verifies('client-auth:invalid-client-clears-all', async (_args: TestArgs) => { expect(as.tokenCalls).toHaveLength(1); expect(defined(as.tokenCalls[0], 'token call').body.get('grant_type')).toBe('refresh_token'); - // Everything is invalidated: tokens are gone and the stale client_id was discarded, - // forcing a fresh dynamic registration on the retry. - expect(provider.invalidatedCredentials).toContain('all'); + // Client + tokens are invalidated (NOT 'all', so discoveryState survives — SEP-2352): + // tokens are gone and the stale client_id was discarded, forcing a fresh dynamic + // registration on the retry. + expect(provider.invalidatedCredentials).toContain('client'); + expect(provider.invalidatedCredentials).toContain('tokens'); expect(provider.saved.tokens).toBeUndefined(); expect(as.registerCalls).toHaveLength(1); expect(provider.saved.clientInformation?.client_id).toBe('registered-client-id'); @@ -921,8 +1276,10 @@ verifies('client-auth:prm-discovery:fallback-order', async (_args: TestArgs) => verifies('client-auth:prm-discovery:no-prm-fallback', async (_args: TestArgs) => { const VALID = 'legacy-fallback-token'; + // RFC 8414 §3.3: metadata fetched at the MCP origin must claim that origin as its issuer. const as = createMockAuthorizationServer({ noPRMDiscovery: true, + asMetadata: { issuer: new URL(MCP_URL).origin }, tokenResponses: [{ access_token: VALID, token_type: 'Bearer' }] }); const provider = new RecordingOAuthClientProvider({ clientInformation: { client_id: 'legacy-fallback-client' } }); @@ -2038,3 +2395,456 @@ verifies('client-transport:sse:401-unauthorized-code', async (_args: TestArgs) = await authTransport.close(); } }); + +// --- SEP-837 / SEP-2207 (DCR hygiene) ------------------------------------------------- + +verifies(['client-auth:dcr:app-type-heuristic', 'client-auth:dcr:grant-types-default'], async (_args: TestArgs) => { + const as = createMockAuthorizationServer(); + // No application_type, no grant_types in clientMetadata; redirect_uri is loopback. + const provider = new RecordingOAuthClientProvider(); + const mcpHost = createAuthenticatedHost('dcr-token'); + const combinedFetch = createCombinedFetch({ as, mcpHost, validToken: 'dcr-token' }); + + const client = new Client({ name: 'c', version: '0' }); + const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch }); + + try { + await expect(client.connect(transport)).rejects.toThrow(UnauthorizedError); + + expect(as.registerCalls).toHaveLength(1); + const body = defined(as.registerCalls[0], 'registration call').body; + // SEP-837: loopback redirect URI → 'native' by heuristic. + expect(body.application_type).toBe('native'); + // SEP-2207: omitted grant_types → defaulted to include refresh_token. + expect(body.grant_types).toEqual(['authorization_code', 'refresh_token']); + } finally { + await client.close(); + await mcpHost.close(); + } +}); + +verifies( + 'client-auth:dcr:app-type-heuristic', + async (_args: TestArgs) => { + // Heuristic 'web' branch: drive registerClient() with a resolveClientMetadata() result so the + // test can use a non-loopback redirect URI without going through auth()'s redirectUrl plumbing. + const as = createMockAuthorizationServer(); + const fetchFn = (url: URL | string, init?: RequestInit) => as.handleRequest(new Request(url, init)); + + await registerClient(ISSUER, { + clientMetadata: resolveClientMetadata({ + clientMetadata: { client_name: 'web-app', redirect_uris: ['https://app.example.com/callback'] }, + redirectUrl: 'https://app.example.com/callback' + }), + fetchFn + }); + + expect(as.registerCalls).toHaveLength(1); + expect(defined(as.registerCalls[0], 'registration call').body.application_type).toBe('web'); + }, + { title: "non-loopback https redirect URI defaults to 'web'" } +); + +verifies('client-auth:dcr:app-type-override', async (_args: TestArgs) => { + const as = createMockAuthorizationServer(); + // Loopback redirect URI but the consumer explicitly says 'web' (e.g. web app dev-served on localhost). + const provider = new RecordingOAuthClientProvider({ clientMetadata: { application_type: 'web' } }); + const mcpHost = createAuthenticatedHost('dcr-token'); + const combinedFetch = createCombinedFetch({ as, mcpHost, validToken: 'dcr-token' }); + + const client = new Client({ name: 'c', version: '0' }); + const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch }); + + try { + await expect(client.connect(transport)).rejects.toThrow(UnauthorizedError); + + expect(as.registerCalls).toHaveLength(1); + const body = defined(as.registerCalls[0], 'registration call').body; + expect(body.application_type).toBe('web'); // heuristic would have picked 'native' + } finally { + await client.close(); + await mcpHost.close(); + } +}); + +verifies('client-auth:dcr:grant-types-default', async (_args: TestArgs) => { + // Consumer-set grant_types is never rewritten. + const as = createMockAuthorizationServer(); + const fetchFn = (url: URL | string, init?: RequestInit) => as.handleRequest(new Request(url, init)); + + await registerClient(ISSUER, { + clientMetadata: { client_name: 'm2m', redirect_uris: ['http://localhost:3000/cb'], grant_types: ['client_credentials'] }, + fetchFn + }); + + expect(as.registerCalls).toHaveLength(1); + expect(defined(as.registerCalls[0], 'registration call').body.grant_types).toEqual(['client_credentials']); +}); + +verifies('client-auth:dcr:registration-rejected-error', async (_args: TestArgs) => { + const as = createMockAuthorizationServer({ + registerErrorResponse: { status: 400, error: 'invalid_redirect_uri', error_description: 'loopback not permitted' } + }); + const provider = new RecordingOAuthClientProvider(); + const mcpHost = createAuthenticatedHost('never'); + const combinedFetch = createCombinedFetch({ as, mcpHost, validToken: 'never' }); + + const client = new Client({ name: 'c', version: '0' }); + const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch }); + + try { + const err = await client.connect(transport).catch((error: unknown) => error); + + // Propagates through auth() — not caught by the OAuthError retry path. + expect(err).toBeInstanceOf(RegistrationRejectedError); + expect(err).not.toBeInstanceOf(OAuthError); + const rre = err as RegistrationRejectedError; + expect(rre.status).toBe(400); + expect(rre.body).toContain('invalid_redirect_uri'); + // Submitted metadata reflects what was POSTed (after defaults applied) so callers can adjust+retry. + expect(rre.submittedMetadata.application_type).toBe('native'); + expect(rre.submittedMetadata.redirect_uris).toEqual(['http://localhost:3000/callback']); + // Exactly one /register call — the auth() recovery path did not silently retry. + expect(as.registerCalls).toHaveLength(1); + } finally { + await client.close(); + await mcpHost.close(); + } +}); + +verifies('client-auth:token-endpoint:https-guard', async (_args: TestArgs) => { + // AS metadata advertises a non-https, non-loopback token endpoint. + const as = createMockAuthorizationServer({ asMetadata: { token_endpoint: 'http://auth.example.com/token' } }); + const provider = new RecordingOAuthClientProvider({ clientInformation: { client_id: 'https-guard-client' } }); + provider.saveCodeVerifier('verifier'); + const fetchFn = (url: URL | string, init?: RequestInit) => as.handleRequest(new Request(url, init)); + + // Exchange path through auth(): redeeming an authorization code is rejected before the request is sent. + await expect(auth(provider, { serverUrl: MCP_URL, authorizationCode: 'code', fetchFn })).rejects.toThrow(InsecureTokenEndpointError); + expect(as.tokenCalls).toHaveLength(0); + + // Refresh path through auth(): a stored refresh_token + non-https token endpoint surfaces the + // configuration error to the caller — it is NOT swallowed into a silent /authorize redirect. + const refreshProvider = new RecordingOAuthClientProvider({ + clientInformation: { client_id: 'https-guard-client' }, + tokens: { access_token: 'old', token_type: 'Bearer', refresh_token: 'rt' } + }); + await expect(auth(refreshProvider, { serverUrl: MCP_URL, fetchFn })).rejects.toThrow(InsecureTokenEndpointError); + expect(as.tokenCalls).toHaveLength(0); + expect(refreshProvider.redirectedTo).toHaveLength(0); + + // And the lower-level helper rejects with the same dedicated class. + await expect( + refreshAuthorization(ISSUER, { + metadata: { + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/authorize`, + token_endpoint: 'http://auth.example.com/token', + response_types_supported: ['code'] + }, + clientInformation: { client_id: 'https-guard-client' }, + refreshToken: 'rt', + fetchFn + }) + ).rejects.toThrow(InsecureTokenEndpointError); + expect(as.tokenCalls).toHaveLength(0); + + // Loopback exemption: the in-process mock AS itself uses an https issuer; cover the exemption + // directly so a future tightening of the guard does not silently break local-dev / test setups. + const loopbackAs = createMockAuthorizationServer({ asMetadata: { token_endpoint: 'http://127.0.0.1:9001/token' } }); + // Route the loopback token URL to the mock. + const loopbackFetch = (url: URL | string, init?: RequestInit) => loopbackAs.handleRequest(new Request(url, init)); + await expect( + refreshAuthorization(ISSUER, { + metadata: { + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/authorize`, + token_endpoint: 'http://127.0.0.1:9001/token', + response_types_supported: ['code'] + }, + clientInformation: { client_id: 'https-guard-client' }, + refreshToken: 'rt', + fetchFn: loopbackFetch + }) + ).resolves.toBeDefined(); +}); + +verifies('client-auth:refresh:rotation-handling', async (_args: TestArgs) => { + const clientInformation = { client_id: 'rotation-client', client_secret: 's' }; + const as = createMockAuthorizationServer({ + tokenResponses: [ + { access_token: 'a1', token_type: 'Bearer' }, // no refresh_token issued + { access_token: 'a2', token_type: 'Bearer' }, // refresh: AS omits refresh_token → keep prior + { access_token: 'a3', token_type: 'Bearer', refresh_token: 'rt-new' } // refresh: rotated + ] + }); + const fetchFn = (url: URL | string, init?: RequestInit) => as.handleRequest(new Request(url, init)); + + // No-assume-issuance: token response without refresh_token parses cleanly. + const t1 = await exchangeAuthorization(ISSUER, { + clientInformation, + authorizationCode: 'code', + codeVerifier: 'verifier', + redirectUri: 'http://localhost:3000/callback', + fetchFn + }); + expect(t1.refresh_token).toBeUndefined(); + + // Prior refresh_token preserved when AS omits a replacement. + const t2 = await refreshAuthorization(ISSUER, { clientInformation, refreshToken: 'rt-old', fetchFn }); + expect(t2.refresh_token).toBe('rt-old'); + + // Rotated refresh_token adopted when AS returns one. + const t3 = await refreshAuthorization(ISSUER, { clientInformation, refreshToken: 'rt-old', fetchFn }); + expect(t3.refresh_token).toBe('rt-new'); +}); + +verifies('client-auth:scope:offline-access-gate', async (_args: TestArgs) => { + // AS advertises offline_access; provider explicitly sets grant_types including refresh_token, + // so offline_access is appended to the requested scope on authorize. + const asWith = createMockAuthorizationServer({ asMetadata: { scopes_supported: ['openid', 'offline_access'] } }); + const providerWith = new RecordingOAuthClientProvider({ + clientMetadata: { grant_types: ['authorization_code', 'refresh_token'] } + }); + const fetchWith = createCombinedFetch({ as: asWith, mcpHost: createAuthenticatedHost('never'), validToken: 'never' }); + + await auth(providerWith, { serverUrl: MCP_URL, fetchFn: fetchWith }); + + const redirectWith = defined(providerWith.redirectedTo[0], 'authorization redirect URL'); + expect(redirectWith.searchParams.get('scope')?.split(' ')).toContain('offline_access'); + + // AS does NOT advertise offline_access → never appended. + const asWithout = createMockAuthorizationServer({ asMetadata: { scopes_supported: ['openid'] } }); + const providerWithout = new RecordingOAuthClientProvider(); + const fetchWithout = createCombinedFetch({ as: asWithout, mcpHost: createAuthenticatedHost('never'), validToken: 'never' }); + + await auth(providerWithout, { serverUrl: MCP_URL, fetchFn: fetchWithout }); + + const redirectWithout = defined(providerWithout.redirectedTo[0], 'authorization redirect URL'); + expect((redirectWithout.searchParams.get('scope') ?? '').split(' ')).not.toContain('offline_access'); +}); + +// --------------------------------------------------------------------------- +// SEP-2352 — per-authorization-server credential isolation. Stored tokens and +// client credentials carry an SDK-stamped `issuer`; a value stamped for a +// different AS reads back as undefined, so it is never reused on the wire. +// --------------------------------------------------------------------------- + +/** + * A protected resource whose `authorization_servers` PRM entry can be swapped + * between calls. Each issuer hosts its own DCR endpoint that mints a distinct + * `client_id`, so reuse of an old-AS `client_id` is observable on the wire. + */ +function createMigratingAuthorizationServer() { + const issuers = { one: 'https://as-one.example.com', two: 'https://as-two.example.com' } as const; + let active: keyof typeof issuers = 'one'; + const registerCalls: Array<{ issuer: string }> = []; + const clientIdsSeen: Array<{ issuer: string; clientId: string | null }> = []; + const tokenCalls: Array<{ issuer: string; body: URLSearchParams }> = []; + + const asMetadata = (issuer: string): AuthorizationServerMetadata => ({ + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + registration_endpoint: `${issuer}/register`, + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + client_id_metadata_document_supported: true, + token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'], + grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'] + }); + + const fetchFn = async (url: URL | string, init?: RequestInit): Promise => { + const u = typeof url === 'string' ? new URL(url) : url; + if (u.pathname.includes('/.well-known/oauth-protected-resource')) { + return Response.json({ resource: RESOURCE, authorization_servers: [issuers[active]] }); + } + if (u.pathname.includes('/.well-known/oauth-authorization-server') || u.pathname.includes('/.well-known/openid-configuration')) { + return Response.json(asMetadata(u.origin)); + } + if (u.pathname === '/register' && init?.method === 'POST') { + const body = z.record(z.string(), z.unknown()).parse(JSON.parse(String(init.body))); + registerCalls.push({ issuer: u.origin }); + return Response.json({ ...body, client_id: `cid-at-${u.host}`, client_secret: `secret-at-${u.host}` }, { status: 201 }); + } + if (u.pathname === '/authorize') { + clientIdsSeen.push({ issuer: u.origin, clientId: u.searchParams.get('client_id') }); + return new Response('Authorize', { status: 200 }); + } + if (u.pathname === '/token' && init?.method === 'POST') { + const body = new URLSearchParams(String(init.body)); + clientIdsSeen.push({ issuer: u.origin, clientId: body.get('client_id') }); + tokenCalls.push({ issuer: u.origin, body }); + return Response.json({ access_token: `tok-${u.host}`, token_type: 'Bearer' }); + } + return new Response('Not Found', { status: 404 }); + }; + + return { + issuers, + registerCalls, + clientIdsSeen, + tokenCalls, + fetchFn, + switchTo(which: keyof typeof issuers) { + active = which; + } + }; +} + +/** Single-slot blob provider — round-trips the SDK-stamped values verbatim. */ +class StampedBlobProvider implements OAuthClientProvider { + redirectedTo: URL[] = []; + info?: StoredOAuthClientInformation; + storedTokens?: StoredOAuthTokens; + discovery?: OAuthDiscoveryState; + private _verifier?: string; + + constructor(public readonly clientMetadataUrl?: string) {} + + get redirectUrl() { + return 'http://localhost:3000/callback'; + } + get clientMetadata() { + return { client_name: 'Test Client', redirect_uris: [this.redirectUrl] }; + } + clientInformation() { + return this.info; + } + saveClientInformation(i: OAuthClientInformationMixed) { + this.info = i; + } + tokens() { + return this.storedTokens; + } + saveTokens(t: OAuthTokens) { + this.storedTokens = t; + } + redirectToAuthorization(u: URL) { + this.redirectedTo.push(u); + } + saveCodeVerifier(v: string) { + this._verifier = v; + } + codeVerifier() { + if (!this._verifier) throw new Error('no verifier'); + return this._verifier; + } + discoveryState() { + return this.discovery; + } + saveDiscoveryState(s: OAuthDiscoveryState) { + this.discovery = s; + } + invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery') { + if (scope === 'all' || scope === 'discovery') this.discovery = undefined; + } +} + +verifies('client-auth:as-migration:reregister', async (_args: TestArgs) => { + const server = createMigratingAuthorizationServer(); + const provider = new StampedBlobProvider(); + + expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('REDIRECT'); + expect(server.registerCalls).toEqual([{ issuer: server.issuers.one }]); + expect(provider.info?.issuer).toBe(server.issuers.one); + + // PRM now lists AS-two. Drop cached discovery (as a host would on a fresh 401) so + // re-discovery picks up the new AS. + server.switchTo('two'); + provider.invalidateCredentials('discovery'); + expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('REDIRECT'); + + // Stamp mismatch → undefined → re-registers at AS-two, redirect carries the fresh client_id. + expect(server.registerCalls).toEqual([{ issuer: server.issuers.one }, { issuer: server.issuers.two }]); + const redirect = defined(provider.redirectedTo.at(-1), 'second redirect'); + expect(redirect.origin).toBe(server.issuers.two); + expect(redirect.searchParams.get('client_id')).toBe('cid-at-as-two.example.com'); +}); + +verifies('client-auth:as-migration:no-cred-reuse', async (_args: TestArgs) => { + const server = createMigratingAuthorizationServer(); + const provider = new StampedBlobProvider(); + + expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('REDIRECT'); + expect(provider.info?.client_id).toBe('cid-at-as-one.example.com'); + + server.switchTo('two'); + provider.invalidateCredentials('discovery'); + await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn }); + + // Wire MUST: AS-two never received AS-one's client_id at any endpoint. + for (const seen of server.clientIdsSeen.filter(c => c.issuer === server.issuers.two)) { + expect(seen.clientId).not.toBe('cid-at-as-one.example.com'); + } + expect(provider.info?.client_id).toBe('cid-at-as-two.example.com'); + expect(provider.info?.issuer).toBe(server.issuers.two); +}); + +verifies('client-auth:as-migration:no-token-reuse', async (_args: TestArgs) => { + const server = createMigratingAuthorizationServer(); + const provider = new StampedBlobProvider(); + + // Completed flow against AS-one — provider holds an AS-one-stamped refresh_token. + expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('REDIRECT'); + expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn, authorizationCode: 'code-a' })).toBe('AUTHORIZED'); + provider.storedTokens = { ...defined(provider.storedTokens, 'tokens'), refresh_token: 'rt-one' }; + expect(provider.storedTokens.issuer).toBe(server.issuers.one); + + server.switchTo('two'); + provider.invalidateCredentials('discovery'); + expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('REDIRECT'); + + // Wire MUST: AS-two's /token never received a refresh_token grant or rt-one. + for (const { body } of server.tokenCalls.filter(c => c.issuer === server.issuers.two)) { + expect(body.get('grant_type')).not.toBe('refresh_token'); + expect(body.get('refresh_token')).not.toBe('rt-one'); + } +}); + +verifies('client-auth:as-migration:cimd-portable', async (_args: TestArgs) => { + const cimdUrl = 'https://client.example.com/.well-known/client-metadata.json'; + const server = createMigratingAuthorizationServer(); + const provider = new StampedBlobProvider(cimdUrl); + + expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('REDIRECT'); + expect(server.registerCalls).toHaveLength(0); + expect(provider.info?.client_id).toBe(cimdUrl); + + server.switchTo('two'); + provider.invalidateCredentials('discovery'); + expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('REDIRECT'); + + // No DCR; the same URL-based client_id is presented to the new AS (re-stamped). + expect(server.registerCalls).toHaveLength(0); + const redirect = defined(provider.redirectedTo.at(-1), 'second redirect'); + expect(redirect.origin).toBe(server.issuers.two); + expect(redirect.searchParams.get('client_id')).toBe(cimdUrl); + expect(provider.info?.issuer).toBe(server.issuers.two); +}); + +verifies('client-auth:as-migration:m2m-expected-issuer', async (_args: TestArgs) => { + const server = createMigratingAuthorizationServer(); + + // expectedIssuer = AS-one, but PRM points to AS-two → stamp mismatch → undefined → + // no saveClientInformation → AuthorizationServerMismatchError before any token request. + server.switchTo('two'); + const provider = new ClientCredentialsProvider({ + clientId: 'static-cid', + clientSecret: 'static-secret', + expectedIssuer: server.issuers.one + }); + await expect(auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).rejects.toThrow(AuthorizationServerMismatchError); + expect(server.tokenCalls.filter(c => c.issuer === server.issuers.two)).toHaveLength(0); + expect(server.clientIdsSeen.filter(c => c.issuer === server.issuers.two)).toHaveLength(0); + + // Matching expectedIssuer proceeds and stamps the saved tokens. + server.switchTo('one'); + const ok = new ClientCredentialsProvider({ + clientId: 'static-cid', + clientSecret: 'static-secret', + expectedIssuer: server.issuers.one + }); + expect(await auth(ok, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('AUTHORIZED'); + expect(ok.tokens()?.issuer).toBe(server.issuers.one); +}); diff --git a/test/e2e/scenarios/handler-context.test.ts b/test/e2e/scenarios/handler-context.test.ts index 81c5a776ba..f5624faf99 100644 --- a/test/e2e/scenarios/handler-context.test.ts +++ b/test/e2e/scenarios/handler-context.test.ts @@ -44,7 +44,10 @@ verifies('mcpserver:context:log-from-handler', async ({ transport }: TestArgs) = await using _ = await wire(transport, makeServer, client); - const inFlightCall = client.callTool({ name: 'emit-log', arguments: {} }); + // On a 2026-era request the spec says an absent `_meta.logLevel` envelope key means the server MUST NOT + // send notifications/message — so the entryModern arm needs the key set explicitly for the log to be + // emitted. Legacy-era arms ignore the key (the session-scoped level applies; absent → no filter). + const inFlightCall = client.callTool({ name: 'emit-log', arguments: {}, _meta: { 'io.modelcontextprotocol/logLevel': 'debug' } }); try { // The handler is parked on the gate, so the tools/call request is still in flight when the log arrives. await vi.waitFor(() => expect(logs).toHaveLength(1)); diff --git a/test/e2e/scenarios/hosting-entry-auth.test.ts b/test/e2e/scenarios/hosting-entry-auth.test.ts new file mode 100644 index 0000000000..a618250207 --- /dev/null +++ b/test/e2e/scenarios/hosting-entry-auth.test.ts @@ -0,0 +1,337 @@ +/** + * Bearer auth composed with the dual-era HTTP entry (`createMcpHandler`), and + * per-request HTTP context exposure on it. These are the entry-side siblings of + * `hosting:auth:authinfo-propagates` / `hosting:auth:missing-401` / + * `hosting:context:web-request-headers`, whose bodies hand-host an Express or + * per-session stack and so never reach `createMcpHandler` when given an entry + * arm. + * + * The SDK does not enforce endpoint authentication on either era — bearer auth + * is deployer-composed middleware in front of whichever handler is mounted. + * The composition under test here is the documented one: a user-shaped gate + * verifies the Authorization header and, on success, hands the verified + * `AuthInfo` to `handler.fetch(request, { authInfo })`. The entry never derives + * `authInfo` from request headers; it is strictly pass-through to the factory's + * per-request context and to handler `ctx.http.authInfo`. Each cell hosts the + * composition itself behind an in-process fetch (the wire() entry arm has no + * hook for the gate or for client-transport requestInit), and the matrix arm + * selects which leg of the entry serves the authenticated traffic + * (`entryStateless` → a plain client through the stateless legacy fallback; + * `entryModern` → a 2026-07-28-pinned client through the modern-only strict + * path). + */ +import { Client, InsufficientScopeError, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import type { AuthInfo, McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; +import { z } from 'zod/v4'; + +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const MODERN = '2026-07-28'; +const VALID_TOKEN = 'e2e-entry-access-token'; + +/** What the user's verifier derives from the Authorization header (a fresh object per request, so the assertion checks delivery, not identity). */ +function verifyBearer(header: string | null): AuthInfo | undefined { + if (header !== `Bearer ${VALID_TOKEN}`) return undefined; + return { + token: VALID_TOKEN, + clientId: 'e2e-entry-caller', + scopes: ['mcp:tools:read', 'mcp:tools:call'], + extra: { userId: 'user-42' } + }; +} + +/** The 401 a bearer gate answers a missing/invalid token with (mirrors the spec's WWW-Authenticate discovery shape). */ +function unauthorized(): Response { + return Response.json( + { error: 'invalid_token' }, + { + status: 401, + headers: { + 'content-type': 'application/json', + 'www-authenticate': + 'Bearer error="invalid_token", resource_metadata="http://in-process/.well-known/oauth-protected-resource"' + } + } + ); +} + +verifies('typescript:hosting:entry:auth:missing-401', async ({ transport }: TestArgs) => { + let factoryCalls = 0; + const factory = (_ctx?: McpRequestContext): McpServer => { + factoryCalls++; + const server = new McpServer({ name: 'e2e-entry-auth', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('whoami', { inputSchema: z.object({}) }, () => ({ content: [{ type: 'text', text: 'reached' }] })); + return server; + }; + + const handler = createMcpHandler(factory, { legacy: transport === 'entryStateless' ? 'stateless' : 'reject' }); + await using _ = { [Symbol.asyncDispose]: () => handler.close() }; + + // The documented bearer-gate composition in front of the entry. + const gatedFetch = async (url: URL | string, init?: RequestInit): Promise => { + const request = new Request(url, init); + const authInfo = verifyBearer(request.headers.get('authorization')); + if (authInfo === undefined) return unauthorized(); + return handler.fetch(request, { authInfo }); + }; + + // 1. Raw probe without an Authorization header: the gate answers 401 with + // the WWW-Authenticate challenge, and the entry is never reached. + const probe = await gatedFetch('http://in-process/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }) + }); + expect(probe.status).toBe(401); + const wwwAuthenticate = probe.headers.get('www-authenticate'); + expect(wwwAuthenticate).toContain('Bearer'); + expect(wwwAuthenticate).toContain('resource_metadata'); + expect(factoryCalls).toBe(0); + + // 2. The plain SDK client (no Authorization on its requestInit) cannot + // connect through the gate on either leg, and the entry is still never + // reached. The exact connect-time error surface (401 wrapped by the + // legacy POST or by the modern discover negotiation) is a client-auth + // concern; this cell pins only that the gate composes — connect rejects + // and no factory call runs. + const plainClient = new Client({ name: 'plain-client', version: '1.0.0' }); + if (transport === 'entryModern') plainClient.setVersionNegotiation({ mode: { pin: MODERN } }); + try { + await expect( + plainClient.connect(new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch: gatedFetch })) + ).rejects.toThrow(); + } finally { + await plainClient.close().catch(() => {}); + } + expect(factoryCalls).toBe(0); +}); + +verifies('typescript:hosting:entry:auth:authinfo-propagates', async ({ transport }: TestArgs) => { + // Recorders live outside the per-request factory. + const seenByFactory: Array = []; + const seenByTool: Array = []; + + const factory = (ctx?: McpRequestContext): McpServer => { + seenByFactory.push(ctx?.authInfo); + const server = new McpServer({ name: 'e2e-entry-auth', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool( + 'whoami', + { description: 'Reports the authenticated caller derived from ctx.http.authInfo.', inputSchema: z.object({}) }, + (_args, handlerCtx) => { + seenByTool.push(handlerCtx.http?.authInfo); + return { + content: [ + { + type: 'text', + text: handlerCtx.http?.authInfo + ? `${handlerCtx.http.authInfo.clientId} [${handlerCtx.http.authInfo.scopes.join(' ')}] (${ctx?.era ?? 'unknown'})` + : 'no-auth-info' + } + ] + }; + } + ); + return server; + }; + + const handler = createMcpHandler(factory, { legacy: transport === 'entryStateless' ? 'stateless' : 'reject' }); + const gatedFetch = async (url: URL | string, init?: RequestInit): Promise => { + const request = new Request(url, init); + const authInfo = verifyBearer(request.headers.get('authorization')); + if (authInfo === undefined) return unauthorized(); + return handler.fetch(request, { authInfo }); + }; + + const client = new Client({ name: 'auth-client', version: '1.0.0' }); + if (transport === 'entryModern') client.setVersionNegotiation({ mode: { pin: MODERN } }); + await using _ = { + [Symbol.asyncDispose]: async () => { + await client.close().catch(() => {}); + await handler.close(); + } + }; + + await client.connect( + new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { + fetch: gatedFetch, + requestInit: { headers: { Authorization: `Bearer ${VALID_TOKEN}` } } + }) + ); + if (transport === 'entryModern') expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + const result = await client.callTool({ name: 'whoami', arguments: {} }); + expect(result.isError).toBeFalsy(); + const era = transport === 'entryStateless' ? 'legacy' : 'modern'; + expect(result.content).toEqual([{ type: 'text', text: `e2e-entry-caller [mcp:tools:read mcp:tools:call] (${era})` }]); + + // The verified AuthInfo handed to handler.fetch(request, { authInfo }) + // reached the tool handler's ctx.http.authInfo unchanged — not dropped, not + // replaced by a placeholder, not derived from a header. + expect(seenByTool).toHaveLength(1); + expect(seenByTool[0]).toEqual({ + token: VALID_TOKEN, + clientId: 'e2e-entry-caller', + scopes: ['mcp:tools:read', 'mcp:tools:call'], + extra: { userId: 'user-42' } + }); + + // ...and the same AuthInfo was on the factory's per-request context for + // every instance the entry built (negotiation + the tools/call), so a + // factory keying surface off authInfo sees it on the leg under test. + expect(seenByFactory.length).toBeGreaterThan(0); + for (const seen of seenByFactory) { + expect(seen).toEqual({ + token: VALID_TOKEN, + clientId: 'e2e-entry-caller', + scopes: ['mcp:tools:read', 'mcp:tools:call'], + extra: { userId: 'user-42' } + }); + } +}); + +verifies('typescript:hosting:entry:ctx-http-req-headers', async ({ transport }: TestArgs) => { + const PROBE_HEADER = 'x-e2e-probe'; + const PROBE_VALUE = 'probe-7d1f'; + const seenByTool: Array<{ isFetchHeaders: boolean; probe: string | null }> = []; + + const factory = (_ctx?: McpRequestContext): McpServer => { + const server = new McpServer({ name: 'e2e-entry-ctx', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('read-probe-header', { inputSchema: z.object({}) }, (_args, ctx) => { + const headers = ctx.http?.req?.headers; + seenByTool.push({ + isFetchHeaders: headers instanceof Headers, + probe: headers instanceof Headers ? headers.get(PROBE_HEADER) : null + }); + return { content: [{ type: 'text', text: headers?.get(PROBE_HEADER) ?? '' }] }; + }); + return server; + }; + + const handler = createMcpHandler(factory, { legacy: transport === 'entryStateless' ? 'stateless' : 'reject' }); + const client = new Client({ name: 'ctx-client', version: '1.0.0' }); + if (transport === 'entryModern') client.setVersionNegotiation({ mode: { pin: MODERN } }); + await using _ = { + [Symbol.asyncDispose]: async () => { + await client.close().catch(() => {}); + await handler.close(); + } + }; + + await client.connect( + new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { + fetch: (url, init) => handler.fetch(new Request(url, init)), + requestInit: { headers: { [PROBE_HEADER]: PROBE_VALUE } } + }) + ); + + const result = await client.callTool({ name: 'read-probe-header', arguments: {} }); + expect(result.isError).toBeFalsy(); + expect(result.content).toEqual([{ type: 'text', text: PROBE_VALUE }]); + // The custom header set on the client transport is readable as Fetch + // Headers inside the handler on the leg the matrix arm selected. + expect(seenByTool).toEqual([{ isFetchHeaders: true, probe: PROBE_VALUE }]); +}); + +verifies('typescript:hosting:entry:auth:insufficient-scope-403', async ({ transport }: TestArgs) => { + // Per-operation scope requirements derived from the body's tool name. On the + // modern leg the gate reads the SEP-2243 standard `Mcp-Method` / `Mcp-Name` + // headers (the entry's documented per-operation routing surface); the legacy + // leg has no such header so the gate falls back to one required scope. + const REQUIRED_BY_TOOL: Record = { 'write-file': 'files:write' }; + const TOKEN_SCOPES: Record = { + 'read-only-token': ['files:read'], + 'read-write-token': ['files:read', 'files:write'] + }; + + let factoryCalls = 0; + const factory = (ctx?: McpRequestContext): McpServer => { + factoryCalls++; + const server = new McpServer({ name: 'e2e-entry-scoped', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('list-files', { inputSchema: z.object({}) }, () => ({ + content: [{ type: 'text', text: `listed by ${ctx?.authInfo?.clientId}` }] + })); + server.registerTool('write-file', { inputSchema: z.object({}) }, () => ({ + content: [{ type: 'text', text: `written by ${ctx?.authInfo?.clientId}` }] + })); + return server; + }; + + const handler = createMcpHandler(factory, { legacy: transport === 'entryStateless' ? 'stateless' : 'reject' }); + await using _ = { [Symbol.asyncDispose]: () => handler.close() }; + + const insufficientScope = (required: string): Response => + Response.json( + { error: 'insufficient_scope' }, + { + status: 403, + headers: { + 'www-authenticate': `Bearer error="insufficient_scope", scope="${required}", error_description="${required} required for this operation"` + } + } + ); + + const gatedFetch = async (url: URL | string, init?: RequestInit): Promise => { + const request = new Request(url, init); + const auth = request.headers.get('authorization'); + const token = auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length) : undefined; + if (!token) return unauthorized(); + const scopes = TOKEN_SCOPES[token]; + if (scopes === undefined) return unauthorized(); + const mcpName = request.headers.get('mcp-name') ?? undefined; + const required: string = + request.headers.get('mcp-method') === 'tools/call' && mcpName ? (REQUIRED_BY_TOOL[mcpName] ?? 'files:read') : 'files:read'; + if (!scopes.includes(required)) return insufficientScope(required); + return handler.fetch(request, { authInfo: { token, clientId: 'e2e-scoped-caller', scopes } }); + }; + + // 1. With the read-only token: list-files reaches the entry; write-file + // (modern leg) is rejected at the gate with 403 + insufficient_scope and + // the entry is never reached for that request. + const before = factoryCalls; + const readClient = new Client({ name: 'scoped-client', version: '1.0.0' }); + if (transport === 'entryModern') readClient.setVersionNegotiation({ mode: { pin: MODERN } }); + await readClient.connect( + new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { + fetch: gatedFetch, + requestInit: { headers: { Authorization: 'Bearer read-only-token' } }, + onInsufficientScope: 'throw' + }) + ); + const listed = await readClient.callTool({ name: 'list-files', arguments: {} }); + expect(listed.content).toEqual([{ type: 'text', text: 'listed by e2e-scoped-caller' }]); + const reachedAfterList = factoryCalls; + expect(reachedAfterList).toBeGreaterThan(before); + + if (transport === 'entryModern') { + const writePromise = readClient.callTool({ name: 'write-file', arguments: {} }); + await expect(writePromise).rejects.toBeInstanceOf(InsufficientScopeError); + await expect(writePromise).rejects.toMatchObject({ requiredScope: 'files:write' }); + // The 403 came from the gate; no factory call ran for that POST. + expect(factoryCalls).toBe(reachedAfterList); + } else { + // Legacy leg: no Mcp-Name header → the gate's per-operation derivation + // is not available, so write-file passes the gate (single required scope + // fallback). The cell pins that the gate composes correctly with the + // legacy serving path; per-operation enforcement on legacy is host + // responsibility (e.g., by parsing the body). + const written = await readClient.callTool({ name: 'write-file', arguments: {} }); + expect(written.content).toEqual([{ type: 'text', text: 'written by e2e-scoped-caller' }]); + } + await readClient.close().catch(() => {}); + + // 2. With the read-write token: write-file reaches the entry on both legs. + const rwClient = new Client({ name: 'scoped-client', version: '1.0.0' }); + if (transport === 'entryModern') rwClient.setVersionNegotiation({ mode: { pin: MODERN } }); + await rwClient.connect( + new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { + fetch: gatedFetch, + requestInit: { headers: { Authorization: 'Bearer read-write-token' } } + }) + ); + const written = await rwClient.callTool({ name: 'write-file', arguments: {} }); + expect(written.content).toEqual([{ type: 'text', text: 'written by e2e-scoped-caller' }]); + await rwClient.close().catch(() => {}); +}); diff --git a/test/e2e/scenarios/hosting-entry-http.test.ts b/test/e2e/scenarios/hosting-entry-http.test.ts new file mode 100644 index 0000000000..8712da381a --- /dev/null +++ b/test/e2e/scenarios/hosting-entry-http.test.ts @@ -0,0 +1,155 @@ +/** + * HTTP request mechanics on the dual-era HTTP entry (`createMcpHandler`), + * exercised through the wire() entry arms. These are the entry-side siblings + * of the `hosting:http:*` / `hosting:stateless:*` rows, whose bodies hand-host + * the streamable HTTP server transport themselves and so never reach + * `createMcpHandler` when given an entry arm. Every probe here goes through + * `wired.fetch` against the harness-hosted entry so the HTTP status/body is + * observed directly; the matrix arm selects which leg of the entry answers it + * (`entryStateless` → the stateless legacy fallback; `entryModern` → the + * modern-only strict path). + */ +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 { wire } from '../helpers/index.js'; +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const LEGACY = '2025-11-25'; + +/** One ctx-taking factory backing every cell. */ +function echoFactory(_ctx?: McpRequestContext): McpServer { + const server = new McpServer({ name: 'e2e-entry-http', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + return server; +} + +verifies('typescript:hosting:entry:method-405', async ({ transport }: TestArgs) => { + const client = new Client({ name: 'method-405-client', version: '1.0.0' }); + // No `entry` override: the arm posture (`stateless` on entryStateless, + // `reject` on entryModern) is the configuration under test. + await using wired = await wire(transport, echoFactory, client); + + for (const method of ['GET', 'DELETE', 'PUT', 'PATCH']) { + const response = await wired.fetch!(wired.url!, { method }); + expect(response.status).toBe(405); + const body = (await response.json()) as { jsonrpc: string; error: { code: number; message: string } }; + expect(body.jsonrpc).toBe('2.0'); + expect(body.error.code).toBe(-32_000); + expect(body.error.message).toMatch(/method not allowed/i); + } +}); + +verifies('typescript:hosting:entry:parse-error-400', async ({ transport }: TestArgs) => { + const client = new Client({ name: 'parse-error-client', version: '1.0.0' }); + await using wired = await wire(transport, echoFactory, client); + + const response = await wired.fetch!(wired.url!, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: 'not json' + }); + expect(response.status).toBe(400); + const body = (await response.json()) as { jsonrpc: string; error: { code: number } }; + expect(body.jsonrpc).toBe('2.0'); + expect(body.error.code).toBe(-32_700); +}); + +verifies('typescript:hosting:entry:legacy-accept-406', async ({ transport }: TestArgs) => { + const client = new Client({ name: 'accept-406-client', version: '1.0.0' }); + await using wired = await wire(transport, echoFactory, client); + + const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); + + // The legacy server transport requires both application/json and + // text/event-stream on POST: each single-type Accept (and an absent + // Accept) is rejected at 406 by the fallback the entry delegated to. + for (const accept of ['application/json', 'text/event-stream', undefined]) { + const response = await wired.fetch!(wired.url!, { + method: 'POST', + headers: { + 'mcp-protocol-version': LEGACY, + 'content-type': 'application/json', + ...(accept !== undefined && { accept }) + }, + body + }); + expect(response.status).toBe(406); + } +}); + +verifies('typescript:hosting:entry:legacy-content-type-415', async ({ transport }: TestArgs) => { + const client = new Client({ name: 'content-type-415-client', version: '1.0.0' }); + await using wired = await wire(transport, echoFactory, client); + + // A non-JSON Content-Type carrying a syntactically-JSON 2025-era body: the + // entry classifier reads the body (it does not gate on Content-Type), routes + // it legacy, and the fallback's transport answers 415 on Content-Type alone. + const response = await wired.fetch!(wired.url!, { + method: 'POST', + headers: { + 'mcp-protocol-version': LEGACY, + 'content-type': 'text/plain', + accept: 'application/json, text/event-stream' + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }) + }); + expect(response.status).toBe(415); +}); + +verifies('typescript:hosting:entry:legacy-protocol-version-header-400', async ({ transport }: TestArgs) => { + const client = new Client({ name: 'protocol-version-400-client', version: '1.0.0' }); + await using wired = await wire(transport, echoFactory, client); + + // An unknown (and not modern-era) protocol-version header on a 2025-era + // body: the entry routes it legacy, and the fallback's transport answers + // the spec's 400 with the supported version(s) named in the body. + const response = await wired.fetch!(wired.url!, { + method: 'POST', + headers: { + 'mcp-protocol-version': '1999-01-01', + 'content-type': 'application/json', + accept: 'application/json, text/event-stream' + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }) + }); + expect(response.status).toBe(400); + expect(await response.text()).toContain(LEGACY); +}); + +verifies('typescript:hosting:entry:legacy-protocol-version-default', async ({ transport }: TestArgs) => { + const client = new Client({ name: 'protocol-version-default-client', version: '1.0.0' }); + await using wired = await wire(transport, echoFactory, client); + + // No MCP-Protocol-Version header at all: the legacy fallback assumes the + // spec's default version, so a 2025-era tools/list still round-trips. + 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: 'tools/list', params: {} }) + }); + expect(response.status).toBe(200); + expect(await response.text()).toContain('"echo"'); +}); + +verifies('typescript:hosting:entry:no-session-id', async ({ transport }: TestArgs) => { + const client = new Client({ name: 'no-session-id-client', version: '1.0.0' }); + await using wired = await wire(transport, echoFactory, client); + + // A typed round trip through the wired client (so both the connect-time + // negotiation and a follow-up request are recorded), then assert no + // exchange ever carried an Mcp-Session-Id response header. + const result = await client.callTool({ name: 'echo', arguments: { text: 'probe' } }); + expect(result.content).toEqual([{ type: 'text', text: 'probe' }]); + + expect(wired.httpLog!.length).toBeGreaterThan(0); + for (const exchange of wired.httpLog!) { + expect(exchange.response.headers.get('mcp-session-id')).toBeNull(); + } +}); 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..2a09b95d42 --- /dev/null +++ b/test/e2e/scenarios/hosting-entry-session.test.ts @@ -0,0 +1,197 @@ +/** + * Sessionful 2025-era serving kept alive next to a strict dual-era HTTP entry + * through explicit user-land routing: the exported `isLegacyRequest` predicate + * (the entry's own classification step) decides, an existing sessionful wiring + * serves the legacy branch, and a strict (`legacy: 'reject'`) `createMcpHandler` + * serves everything else. This is the documented replacement for the removed + * handler-valued `legacy` option. + * + * The legacy wiring is real and sessionful — 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 routed composition: 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 + * wiring serves is recorded as it leaves it (method, status, content-type), so + * the predicate's routing of GET/DELETE (no envelope, no body → legacy) is + * pinned directly; byte-level forwarding fidelity is not asserted here. An + * envelope-claiming probe at the end pins that modern traffic is answered by + * the strict entry, never by the legacy wiring. + * + * The composition is hosted by the test body itself (an in-process fetch in + * front of both handlers), so the wire() entry arm is not used; the matrix + * still bounds the cell to the 2025-11-25 axis via the requirement entry. + */ +import { randomUUID } from 'node:crypto'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import type { LegacyHttpHandler, McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, isLegacyRequest, McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { expect, vi } from 'vitest'; +import { z } from 'zod/v4'; + +import { modernEnvelopeMeta } from '../helpers/index.js'; +import { verifies } from '../helpers/verifies.js'; + +const LEGACY = '2025-11-25'; + +/** The factory backing the strict modern entry; legacy traffic never reaches it (the lifecycle under test is the legacy wiring'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 () => { + // The documented sessionful wiring, kept exactly as an existing deployment + // would have it: 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): Promise { + const sessionId = request.headers.get('mcp-session-id'); + if (sessionId !== null) { + const existing = sessions.get(sessionId); + if (existing !== undefined) return existing.handleRequest(request); + // 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: 'sessionful-legacy-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name} (legacy session)` }] + })); + sessionServers.push(server); + await server.connect(transport); + return transport.handleRequest(request); + } + + // Every exchange routed to the existing legacy wiring, recorded as it + // leaves the wiring: this is what proves the GET/DELETE routing. + const legacyExchanges: Array<{ method: string; status: number; contentType: string }> = []; + const sessionfulLegacy: LegacyHttpHandler = async request => { + const response = await routeSessionRequest(request); + legacyExchanges.push({ + method: request.method.toUpperCase(), + status: response.status, + contentType: response.headers.get('content-type') ?? '' + }); + return response; + }; + + // The documented user-land routing pattern: a strict modern entry plus the + // exported predicate in front of the existing legacy wiring. + const modern = createMcpHandler(modernFactory, { legacy: 'reject' }); + const route = async (request: Request): Promise => { + if (await isLegacyRequest(request)) { + return sessionfulLegacy(request); + } + return modern.fetch(request); + }; + const url = new URL('http://in-process/mcp'); + const fetchViaRouter = (input: URL | string, init?: RequestInit) => route(new Request(input, init)); + + const client = new Client({ name: 'plain-2025-client', version: '1.0.0' }); + try { + await client.connect(new StreamableHTTPClientTransport(url, { fetch: fetchViaRouter })); + + // initialize → the sessionful wiring issues an Mcp-Session-Id. (The + // strict entry never issues one, so a defined session id alone proves + // the request was routed to the existing legacy 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 (legacy session)' }]); + expect(clientTransport.sessionId).toBe(sessionId); + + // GET route: the client opens its standalone SSE stream after + // initialization; the predicate routes the body-less GET (no envelope) + // to the legacy wiring, which answers it with the stream. + await vi.waitFor( + () => { + const get = legacyExchanges.find(exchange => exchange.method === 'GET'); + if (get === undefined) throw new Error('the standalone GET stream has not reached the legacy wiring 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 predicate to + // the sessionful wiring, which tears the session down. + await clientTransport.terminateSession(); + expect(closedSessions).toEqual([sessionId]); + const deleteExchange = legacyExchanges.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 sessionful wiring, not silently re-served by anything else. + const stale = await fetchViaRouter(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 sessionful wiring (the probe + // reached it), not synthesized by the entry or anything in front of it. + expect(legacyExchanges.some(exchange => exchange.method === 'POST' && exchange.status === 404)).toBe(true); + + // Modern traffic is the strict entry's: an envelope-claiming request is + // answered by the modern factory and never reaches the legacy wiring. + const exchangesBeforeModernProbe = legacyExchanges.length; + const modernProbe = await fetchViaRouter(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-method': 'tools/call', + 'mcp-name': 'greet' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 100, + method: 'tools/call', + params: { + name: 'greet', + arguments: { name: 'router' }, + _meta: modernEnvelopeMeta({ name: 'router-probe-client', version: '1.0.0' }) + } + }) + }); + expect(modernProbe.status).toBe(200); + expect(await modernProbe.text()).toContain('hello router (modern)'); + expect(legacyExchanges).toHaveLength(exchangesBeforeModernProbe); + } finally { + await client.close().catch(() => {}); + await modern.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..6cf4c51078 --- /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' }); + 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..0f0360af09 --- /dev/null +++ b/test/e2e/scenarios/hosting-entry-streaming.test.ts @@ -0,0 +1,154 @@ +/** + * 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 through the wired client (the + * entryModern arm pins it to 2026-07-28); 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 newClient(): Client { + return new Client({ name: 'e2e-streaming-client', version: '1.0.0' }); +} + +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 = newClient(); + 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 = newClient(); + 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 = newClient(); + 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..d979a810dd --- /dev/null +++ b/test/e2e/scenarios/hosting-entry.test.ts @@ -0,0 +1,178 @@ +/** + * Core cells for the dual-era HTTP entry (`createMcpHandler`), exercised + * through the wire() entry arms: `entryStateless` hosts the entry's stateless + * legacy fallback (the default posture) for plain 2025-era clients (2025-11-25 + * axis) and `entryModern` hosts the modern-only strict (`legacy: 'reject'`) + * 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, the + // 'stateless' legacy posture — driven by a plain client; the entry arm + // decides which era serves it (entryModern pins the client to 2026-07-28). + const client = new Client({ name: 'dual-era-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 + // stateless legacy fallback — 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 arm-pinned 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 (legacy: 'reject' — the entryModern arm hosting): the pinned client never needs the legacy leg. + 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:client:connect:prior-zero-roundtrip', async ({ transport }: TestArgs) => { + // Bootstrap: the wired negotiating client (the entryModern arm pins it to + // the modern revision) populates getDiscoverResult(). + const bootstrap = new Client({ name: 'bootstrap', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await using wired = await wire(transport, greetFactory, bootstrap); + const prior = bootstrap.getDiscoverResult(); + expect(prior).toBeDefined(); + expect(prior!.supportedVersions).toContain(MODERN); + + // Fresh worker → SAME hosted server, connect({ prior }): zero round trips. + const before = wired.httpLog!.length; + const worker = new Client({ name: 'worker', version: '1.0.0' }); + await worker.connect(new StreamableHTTPClientTransport(wired.url!, { fetch: wired.fetch }), { prior }); + try { + // No HTTP exchange was added by the worker's connect(). + expect(wired.httpLog!.length).toBe(before); + expect(worker.getNegotiatedProtocolVersion()).toBe(MODERN); + // First wire traffic from the worker is the tools/call itself. + const result = await worker.callTool({ name: 'greet', arguments: { name: 'prior' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hello prior (modern)' }]); + expect(wired.httpLog!.length).toBe(before + 1); + expect(wired.httpLog![before]!.requestBody).toContain('"tools/call"'); + } finally { + await worker.close().catch(() => {}); + } +}); + +verifies('typescript:hosting:entry:strict-rejects-legacy', async ({ transport }: TestArgs) => { + // legacy: 'reject' → modern-only strict (the entryModern arm hosting): 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); + + // 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..85c106f75d 100644 --- a/test/e2e/scenarios/hosting-express.test.ts +++ b/test/e2e/scenarios/hosting-express.test.ts @@ -24,13 +24,16 @@ 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 { AuthorizationParams, OAuthServerProvider } from '@modelcontextprotocol/server-legacy'; +import { mcpAuthRouter } from '@modelcontextprotocol/server-legacy'; import type { Express, RequestHandler } from 'express'; import express from 'express'; import { expect } from 'vitest'; import { z } from 'zod/v4'; import { startExpressMinimal, startExpressWithHostValidation } from '../helpers/express.js'; +import { defined } from '../helpers/index.js'; import { verifies } from '../helpers/verifies.js'; import type { TestArgs } from '../types.js'; @@ -387,6 +390,70 @@ verifies('hosting:auth:query-token-ignored', async (_args: TestArgs) => { expect(headerRes.status).toBe(200); }); +verifies('hosting:auth:as-iss-emission', async (_args: TestArgs) => { + // Minimal OAuthServerProvider whose authorize() issues a plain success redirect WITHOUT + // appending `iss` itself — the bundled handler must add it so the metadata claim is true + // without provider action. + const seenIssuer: Array = []; + const provider: OAuthServerProvider = { + clientsStore: { + // eslint-disable-next-line @typescript-eslint/require-await + async getClient(clientId) { + return clientId === 'demo-client' + ? { client_id: 'demo-client', redirect_uris: ['https://client.example.com/cb'] } + : undefined; + } + }, + // eslint-disable-next-line @typescript-eslint/require-await + async authorize(_client, params: AuthorizationParams, res) { + seenIssuer.push(params.issuer); + const u = new URL(params.redirectUri); + u.searchParams.set('code', 'demo-auth-code'); + if (params.state) u.searchParams.set('state', params.state); + res.redirect(302, u.href); + }, + challengeForAuthorizationCode: async () => 'challenge', + exchangeAuthorizationCode: async () => ({ access_token: 't', token_type: 'Bearer' }), + exchangeRefreshToken: async () => ({ access_token: 't', token_type: 'Bearer' }), + verifyAccessToken: async token => ({ token, clientId: 'demo-client', scopes: [] }) + }; + + const app = express(); + app.use(mcpAuthRouter({ provider, issuerUrl: new URL('http://localhost/'), authorizationOptions: { rateLimit: false } })); + await using host = await startExpressMinimal(app); + + // Metadata advertises RFC 9207 support. + const md = await fetch(new URL('/.well-known/oauth-authorization-server', host.baseUrl)); + expect(md.status).toBe(200); + const mdBody = (await md.json()) as { issuer: string; authorization_response_iss_parameter_supported?: boolean }; + expect(mdBody.authorization_response_iss_parameter_supported).toBe(true); + + // Success redirect: handler supplies issuer to provider.authorize() and appends `iss` to + // the provider's redirect itself (provider did not set it). + const ok = await fetch( + new URL( + '/authorize?client_id=demo-client&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb&response_type=code&code_challenge=abc&code_challenge_method=S256&state=xyz', + host.baseUrl + ), + { redirect: 'manual' } + ); + expect(ok.status).toBe(302); + const okLoc = new URL(defined(ok.headers.get('location'), 'location')); + expect(okLoc.searchParams.get('code')).toBe('demo-auth-code'); + expect(okLoc.searchParams.get('iss')).toBe(mdBody.issuer); + expect(seenIssuer).toEqual([mdBody.issuer]); + + // Error redirect (missing code_challenge → invalid_request): handler appends iss itself. + const err = await fetch( + new URL('/authorize?client_id=demo-client&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb&state=xyz', host.baseUrl), + { redirect: 'manual' } + ); + expect(err.status).toBe(302); + const errLoc = new URL(defined(err.headers.get('location'), 'location')); + expect(errLoc.searchParams.get('error')).toBe('invalid_request'); + expect(errLoc.searchParams.get('iss')).toBe(mdBody.issuer); +}); + /** Listen `app` (already fully configured by the adapter under test) on an ephemeral 127.0.0.1 port; callers close() in finally. */ function listenExpressApp(app: Express): Promise<{ baseUrl: URL; close: () => Promise }> { return new Promise((resolve, reject) => { @@ -475,13 +542,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/jsonschema.test.ts b/test/e2e/scenarios/jsonschema.test.ts new file mode 100644 index 0000000000..8ac82ae587 --- /dev/null +++ b/test/e2e/scenarios/jsonschema.test.ts @@ -0,0 +1,596 @@ +/** + * Self-contained test bodies for the JSON Schema 2020-12 validator posture + * (SEP-1613 dialect, SEP-2106 non-object roots + legacy `{result:…}` wrap). + * + * Each export is a {@link verifies} body: it builds its own server (via a + * factory), builds its own client, wires them with {@link wire}, and asserts. + * There are no shared fixture imports; helpers local to multiple bodies live at + * the top of this file. + * + * The era-spanning bodies use `type:'object'`-rooted output schemas (so the + * 2025-era wire codec — which keeps `outputSchema`/`structuredContent` at their + * object/Record shapes for byte-identity — round-trips them on every arm). The + * non-object-root bodies are restricted to the createMcpHandler entry arms in + * `requirements.ts` because only the 2026-07-28 wire codec carries that + * vocabulary natively. + */ + +import { Client } from '@modelcontextprotocol/client'; +import type { Tool } from '@modelcontextprotocol/server'; +import { fromJsonSchema, McpServer, ProtocolError, ProtocolErrorCode, Server } from '@modelcontextprotocol/server'; +import { expect } 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'; + +/** Plain client with no extra capabilities declared. */ +const newClient = () => new Client({ name: 'c', version: '0' }); + +/** Object-root output schema with a same-document `$ref` into `$defs`. */ +const SAME_DOCUMENT_REF_OUTPUT = { + type: 'object' as const, + properties: { point: { $ref: '#/$defs/Point' } }, + required: ['point'], + $defs: { + Point: { + type: 'object', + properties: { x: { type: 'number' }, y: { type: 'number' } }, + required: ['x', 'y'] + } + } +}; + +/** + * Object-root output schema with an external (network) `$ref`. The SDK does not pre-screen + * `$ref` (the spec MUST-NOT is "do not dereference", not "reject") — the underlying Ajv engine + * does not fetch external refs and throws a `MissingRefError` at compile time, which the client + * captures per-tool and surfaces as `InvalidParams`. + */ +const NETWORK_REF_OUTPUT = { + type: 'object' as const, + properties: { point: { $ref: 'https://schemas.example.invalid/point.json' } }, + required: ['point'] +}; + +/** Object-root output schema declaring a `$schema` dialect URI no built-in provider recognises. */ +const UNKNOWN_DIALECT_OUTPUT = { + $schema: 'https://example.invalid/json-schema/v99/schema', + type: 'object' as const, + properties: { value: { type: 'number' } }, + required: ['value'] +}; + +/** + * Low-level Server factory advertising one tool per fixture output schema. + * The low-level Server applies no server-side output validation, so the + * client-side validator behavior under test is the only check in the path. + */ +function refSchemaServer(): Server { + const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } }); + s.setRequestHandler('tools/list', () => ({ + tools: [ + { + name: 'network-ref', + inputSchema: { type: 'object' }, + outputSchema: NETWORK_REF_OUTPUT + }, + { + name: 'local-ref', + inputSchema: { type: 'object' }, + outputSchema: SAME_DOCUMENT_REF_OUTPUT + }, + { + name: 'unknown-dialect', + inputSchema: { type: 'object' }, + outputSchema: UNKNOWN_DIALECT_OUTPUT + } + ] + })); + s.setRequestHandler('tools/call', req => { + switch (req.params.name) { + case 'network-ref': + case 'local-ref': { + return { structuredContent: { point: { x: 1, y: 2 } }, content: [] }; + } + case 'unknown-dialect': { + return { structuredContent: { value: 7 }, content: [] }; + } + default: { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `unknown tool ${req.params.name}`); + } + } + }); + return s; +} + +verifies('client:jsonschema:same-document-ref-ok', async ({ transport }: TestArgs) => { + const client = newClient(); + await using _ = await wire(transport, refSchemaServer, client); + + const { tools } = await client.listTools(); + const tool = tools.find(t => t.name === 'local-ref'); + expect(tool?.outputSchema).toMatchObject({ $defs: { Point: { type: 'object' } } }); + + const r = await client.callTool({ name: 'local-ref', arguments: {} }); + expect(r.isError).toBeFalsy(); + expect(r.structuredContent).toEqual({ point: { x: 1, y: 2 } }); +}); + +verifies('client:jsonschema:unsupported-dialect-graceful', async ({ transport }: TestArgs) => { + const client = newClient(); + await using _ = await wire(transport, refSchemaServer, client); + + const { tools } = await client.listTools(); + expect(tools.find(t => t.name === 'unknown-dialect')?.outputSchema).toMatchObject({ + $schema: 'https://example.invalid/json-schema/v99/schema' + }); + + const call = client.callTool({ name: 'unknown-dialect', arguments: {} }); + await expect(call).rejects.toBeInstanceOf(ProtocolError); + const err = await call.catch(error => error as ProtocolError); + expect(err.code).toBe(ProtocolErrorCode.InvalidParams); + expect(err.message).toMatch(/invalid outputSchema.*unsupported dialect/i); +}); + +verifies('client:jsonschema:bad-schema-isolates-tool', async ({ transport }: TestArgs) => { + const client = newClient(); + await using _ = await wire(transport, refSchemaServer, client); + + // The listing carries every tool, including the one whose schema the + // validator engine refuses to compile (external `$ref` → MissingRefError). + const { tools } = await client.listTools(); + expect(tools.map(t => t.name).toSorted()).toEqual(['local-ref', 'network-ref', 'unknown-dialect']); + + // The good tool is callable and validates. + const ok = await client.callTool({ name: 'local-ref', arguments: {} }); + expect(ok.isError).toBeFalsy(); + expect(ok.structuredContent).toEqual({ point: { x: 1, y: 2 } }); + + // The bad tool surfaces its compile failure lazily, per-tool. + const bad = client.callTool({ name: 'network-ref', arguments: {} }); + await expect(bad).rejects.toBeInstanceOf(ProtocolError); + const err = await bad.catch(error => error as ProtocolError); + expect(err.code).toBe(ProtocolErrorCode.InvalidParams); + expect(err.message).toMatch(/invalid outputSchema/i); +}); + +verifies('client:jsonschema:non-object-output', async ({ transport }: TestArgs) => { + // Low-level server with a non-object-root output schema. Only meaningful on + // the 2026-07-28 wire codec (entryModern arm), where outputSchema is a + // loose object and structuredContent is `unknown`. + const makeServer = (): Server => { + const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } }); + s.setRequestHandler('tools/list', () => ({ + tools: [ + { + name: 'array-out', + inputSchema: { type: 'object' }, + outputSchema: { type: 'array', items: { type: 'number' } } + } + ] + })); + s.setRequestHandler('tools/call', () => ({ structuredContent: [1, 2, 3], content: [] })); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + const { tools } = await client.listTools(); + expect(tools.find(t => t.name === 'array-out')?.outputSchema).toMatchObject({ type: 'array' }); + + const r = await client.callTool({ name: 'array-out', arguments: {} }); + expect(r.isError).toBeFalsy(); + // SEP-2106: structuredContent is typed `unknown`; narrow at the call site. + expect(r.structuredContent).toEqual([1, 2, 3]); + expect(Array.isArray(r.structuredContent)).toBe(true); +}); + +verifies('client:jsonschema:2020-12:prefixItems', async ({ transport }: TestArgs) => { + // Low-level server advertising a 2020-12-only `prefixItems` outputSchema and + // returning structuredContent in the WRONG positional order. Ajv2020 + // enforces prefixItems → validation fails; a draft-07 Ajv with strict:false + // would ignore the keyword and accept. This pins the SEP-1613 default. + const makeServer = (): Server => { + const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } }); + s.setRequestHandler('tools/list', () => ({ + tools: [ + { + name: 'tuple-out', + inputSchema: { type: 'object' }, + outputSchema: { + type: 'array', + prefixItems: [{ type: 'number' }, { type: 'string' }] + } + } + ] + })); + s.setRequestHandler('tools/call', () => ({ structuredContent: ['x', 1], content: [] })); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + await client.listTools(); + const call = client.callTool({ name: 'tuple-out', arguments: {} }); + await expect(call).rejects.toBeInstanceOf(ProtocolError); + const err = await call.catch(error => error as ProtocolError); + expect(err.code).toBe(ProtocolErrorCode.InvalidParams); + expect(err.message).toMatch(/output schema/i); +}); + +/** + * Low-level Server advertising a `prefixItems` outputSchema with no `$schema` + * stamp. The handler returns structuredContent that violates `prefixItems` + * (positions swapped). With the 2020-12 default, `prefixItems` is enforced and + * validation fails. + */ +function dialectServer(): Server { + const out: Tool['outputSchema'] = { + type: 'object', + properties: { v: { type: 'array', prefixItems: [{ type: 'number' }, { type: 'string' }] } }, + required: ['v'] + }; + const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } }); + s.setRequestHandler('tools/list', () => ({ + tools: [{ name: 'no-stamp', inputSchema: { type: 'object' }, outputSchema: out }] + })); + s.setRequestHandler('tools/call', () => ({ structuredContent: { v: ['x', 1] }, content: [] })); + return s; +} + +verifies('client:jsonschema:dialect:default-is-2020-12', async ({ transport }: TestArgs) => { + const client = newClient(); + await using _ = await wire(transport, dialectServer, client); + + await client.listTools(); + // No `$schema` → 2020-12 default → `prefixItems` enforced → {v:['x',1]} invalid. + const call = client.callTool({ name: 'no-stamp', arguments: {} }); + await expect(call).rejects.toBeInstanceOf(ProtocolError); + const err = await call.catch(error => error as ProtocolError); + expect(err.code).toBe(ProtocolErrorCode.InvalidParams); + expect(err.message).toMatch(/output schema/i); +}); + +verifies('client:jsonschema:falsy-structured-content-validated', async ({ transport }: TestArgs) => { + // Low-level server with `outputSchema:{type:'integer'}` returning + // `structuredContent: 0`. Pins the SEP-2106 §4.3 `=== undefined` presence + // check on the client: a falsy value is treated as PRESENT and validated, + // not as missing. + const makeServer = (): Server => { + const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } }); + s.setRequestHandler('tools/list', () => ({ + tools: [ + { + name: 'zero', + inputSchema: { type: 'object' }, + outputSchema: { type: 'integer' } + } + ] + })); + s.setRequestHandler('tools/call', () => ({ structuredContent: 0, content: [] })); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + await client.listTools(); + const r = await client.callTool({ name: 'zero', arguments: {} }); + expect(r.isError).toBeFalsy(); + expect(r.structuredContent).toBe(0); + expect(r.structuredContent === 0).toBe(true); +}); + +verifies('server:jsonschema:array-structured-content-textfallback', async ({ transport }: TestArgs) => { + const makeServer = (): McpServer => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool( + 'list-numbers', + { + inputSchema: z.object({}), + outputSchema: fromJsonSchema({ type: 'array', items: { type: 'number' } }) + }, + () => ({ structuredContent: [1, 2, 3], content: [] }) + ); + s.registerTool( + 'list-authored', + { + inputSchema: z.object({}), + outputSchema: fromJsonSchema({ type: 'array', items: { type: 'number' } }) + }, + () => ({ structuredContent: [1], content: [{ type: 'text', text: 'mine' }] }) + ); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + const r = await client.callTool({ name: 'list-numbers', arguments: {} }); + expect(r.isError).toBeFalsy(); + expect(r.structuredContent).toEqual([1, 2, 3]); + // The auto-TextContent fallback carries the JSON serialisation because + // the handler authored no `type:'text'` block of its own. + expect(r.content).toContainEqual({ type: 'text', text: JSON.stringify([1, 2, 3]) }); + + // Author opt-out: any author-supplied `type:'text'` block suppresses the + // auto-fallback — exactly the authored block, no JSON-stringify append. + const own = await client.callTool({ name: 'list-authored', arguments: {} }); + expect(own.structuredContent).toEqual([1]); + const textBlocks = (own.content ?? []).filter(c => c.type === 'text'); + expect(textBlocks).toEqual([{ type: 'text', text: 'mine' }]); +}); + +verifies('server:jsonschema:primitive-structured-content', async ({ transport }: TestArgs) => { + const makeServer = (): McpServer => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool( + 'count', + { + inputSchema: z.object({}), + outputSchema: fromJsonSchema({ type: 'number' }) + }, + () => ({ structuredContent: 0, content: [] }) + ); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + const r = await client.callTool({ name: 'count', arguments: {} }); + expect(r.isError).toBeFalsy(); + expect(r.structuredContent).toBe(0); + expect(r.content).toContainEqual({ type: 'text', text: '0' }); +}); + +verifies( + ['2025:jsonschema:non-object-output-wrapped', '2025:jsonschema:non-object-structured-content-wrapped'], + async ({ transport }: TestArgs) => { + // McpServer with a non-object-root outputSchema, served on the 2025 era + // (entryStateless arm). The legacy interop wraps the outputSchema in a + // `{type:'object',properties:{result:…},required:['result']}` envelope so + // 2025 clients can parse it, and wraps the structuredContent as + // `{result: }` so it satisfies the envelope. The auto-TextContent + // fallback also carries the natural JSON serialisation. + const makeServer = (): McpServer => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool( + 'list-numbers', + { + inputSchema: z.object({}), + outputSchema: fromJsonSchema({ type: 'array', items: { type: 'number' } }) + }, + () => ({ structuredContent: [1, 2, 3], content: [] }) + ); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + const { tools } = await client.listTools(); + const tool = tools.find(t => t.name === 'list-numbers'); + expect(tool).toBeDefined(); + // The non-object outputSchema is wrapped in the {result:…} envelope on the legacy projection. + expect(tool?.outputSchema).toMatchObject({ + type: 'object', + properties: { result: { type: 'array', items: { type: 'number' } } }, + required: ['result'] + }); + + // The tool stays callable on the legacy era: structuredContent is wrapped as + // {result:[1,2,3]} so it satisfies both the 2025 wire shape (object-only) and the + // wrapped outputSchema; the auto-TextContent fallback carries the natural value. + const r = await client.callTool({ name: 'list-numbers', arguments: {} }); + expect(r.isError).toBeFalsy(); + expect(r.structuredContent).toEqual({ result: [1, 2, 3] }); + expect(r.content).toContainEqual({ type: 'text', text: JSON.stringify([1, 2, 3]) }); + } +); + +/** + * McpServer with a typeless-root outputSchema (`anyOf:[object, string]` from `z.union`). The + * legacy wrap predicate is per-tool and follows the SCHEMA root (which is non-object → wraps), + * not the runtime value's shape — so on the 2025 era both the object branch `{a:1}` and the + * string branch `"x"` come back wrapped as `structuredContent.result`, and on the 2026 era both + * come back natural. + */ +function unionOutputServer(): McpServer { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool( + 'union-out', + { + inputSchema: z.object({ which: z.enum(['obj', 'str']) }), + outputSchema: z.union([z.object({ a: z.number() }), z.string()]) + }, + ({ which }) => ({ structuredContent: which === 'obj' ? { a: 1 } : 'x', content: [] }) + ); + return s; +} + +verifies('2025:jsonschema:wrap-follows-schema-not-value', async ({ transport }: TestArgs) => { + const client = newClient(); + await using _ = await wire(transport, unionOutputServer, client); + + const { tools } = await client.listTools(); + const wrapped = tools.find(t => t.name === 'union-out')?.outputSchema; + // The typeless `{anyOf:[…]}` root is wrapped in the {result:…} envelope on the legacy projection. + expect(wrapped).toMatchObject({ + type: 'object', + properties: { result: { anyOf: [{ type: 'object' }, { type: 'string' }] } }, + required: ['result'] + }); + + // BOTH branches — including the object-valued one — are wrapped as {result:…} so the + // result satisfies the wrapped schema (and the legacy client validates it). + const obj = await client.callTool({ name: 'union-out', arguments: { which: 'obj' } }); + expect(obj.isError).toBeFalsy(); + expect(obj.structuredContent).toEqual({ result: { a: 1 } }); + + const str = await client.callTool({ name: 'union-out', arguments: { which: 'str' } }); + expect(str.isError).toBeFalsy(); + expect(str.structuredContent).toEqual({ result: 'x' }); + // The string branch is a non-object value, so the era-agnostic auto-TextContent fires. + expect(str.content).toContainEqual({ type: 'text', text: '"x"' }); +}); + +verifies('server:jsonschema:union-output-natural', async ({ transport }: TestArgs) => { + const client = newClient(); + await using _ = await wire(transport, unionOutputServer, client); + + const { tools } = await client.listTools(); + // No wrap on the 2026 era — the natural typeless `{anyOf:[…]}` root is advertised. + expect(tools.find(t => t.name === 'union-out')?.outputSchema).toMatchObject({ + anyOf: [{ type: 'object' }, { type: 'string' }] + }); + + const obj = await client.callTool({ name: 'union-out', arguments: { which: 'obj' } }); + expect(obj.isError).toBeFalsy(); + expect(obj.structuredContent).toEqual({ a: 1 }); + + const str = await client.callTool({ name: 'union-out', arguments: { which: 'str' } }); + expect(str.isError).toBeFalsy(); + expect(str.structuredContent).toBe('x'); + // The auto-TextContent fallback applies on EVERY era for non-object values. + expect(str.content).toContainEqual({ type: 'text', text: '"x"' }); +}); + +verifies('2025:jsonschema:schemaless-non-object-sc-wrapped', async ({ transport }: TestArgs) => { + // Low-level Server with NO advertised outputSchema, returning a non-object + // structuredContent. The 2025 wire shape requires structuredContent to be an + // object — `server.projectCallToolResult` wraps on value shape alone so the + // result is wire-legal even with nothing to consult on the schema side. + const makeServer = (): Server => { + const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } }); + s.setRequestHandler('tools/list', () => ({ + tools: [{ name: 'schemaless', inputSchema: { type: 'object' } }] + })); + s.setRequestHandler('tools/call', () => s.projectCallToolResult({ content: [], structuredContent: [1, 2, 3] }, undefined)); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + await client.listTools(); + const r = await client.callTool({ name: 'schemaless', arguments: {} }); + expect(r.isError).toBeFalsy(); + expect(r.structuredContent).toEqual({ result: [1, 2, 3] }); + expect(r.content).toContainEqual({ type: 'text', text: JSON.stringify([1, 2, 3]) }); +}); + +verifies('2025:jsonschema:ref-rewrite-on-wrap', async ({ transport }: TestArgs) => { + // A non-object outputSchema with a same-document `$ref` (e.g. a recursive array). On the + // legacy era the schema is wrapped under `#/properties/result`, so the `$ref` JSON Pointer + // must be rewritten to keep resolving (`#/items` → `#/properties/result/items`). Mirrors the + // C# SDK's TransformOutputSchemaForLegacyWire. + const NATURAL = { + type: 'array', + items: { anyOf: [{ type: 'number' }, { $ref: '#' }] } + } as const; + const makeServer = (): McpServer => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool('tree', { inputSchema: z.object({}), outputSchema: fromJsonSchema(NATURAL) }, () => ({ + structuredContent: [1, [2, 3]], + content: [] + })); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + const { tools } = await client.listTools(); + const wrapped = tools.find(t => t.name === 'tree')?.outputSchema; + expect(wrapped).toMatchObject({ + type: 'object', + properties: { result: { type: 'array', items: { anyOf: [{ type: 'number' }, { $ref: '#/properties/result' }] } } }, + required: ['result'] + }); + + // The wrapped schema compiles on the client (the rewritten `$ref` resolves) and validates the + // wrapped structuredContent. + const r = await client.callTool({ name: 'tree', arguments: {} }); + expect(r.isError).toBeFalsy(); + expect(r.structuredContent).toEqual({ result: [1, [2, 3]] }); +}); + +verifies('2025:jsonschema:ref-rewrite-scope', async ({ transport }: TestArgs) => { + // The legacy-wrap `$ref` rewrite is position-aware: it applies to `$ref`/`$dynamicRef` in + // subschema positions, but NOT to keyword-position data (`const`/`enum`/`default`/`examples`) + // where a `{$ref:…}` is a literal value. A property NAMED `default`/`const` under + // `properties`/`$defs` is a NAME position whose value IS a subschema — recursed into. The + // rewrite is also `$id`-scoped: a natural schema carrying `$id` keeps its same-document refs + // unrewritten (they resolve against the embedded base, not the wrapper root). + // + // Listing-only assertion: Ajv2020 stack-overflows when the compiled validator for a + // `$dynamicRef` with a JSON-Pointer fragment (rather than a `$dynamicAnchor`) is RUN — compile + // succeeds (fromJsonSchema below calls it eagerly), validation does not — so the tool is + // intentionally never called; the rewrite contract is about the wrapped SCHEMA in tools/list. + const NATURAL = { + anyOf: [{ $dynamicRef: '#/$defs/X' }, { const: { $ref: '#/foo' } }], + $defs: { + X: { + type: 'object', + // The OUTER `default` here is a property NAME under `properties` — its value is a + // subschema in keyword position, so the `$ref` inside the subschema is rewritten; + // the INNER `default`/`examples` are keywords whose values are instance data. + properties: { default: { $ref: '#/$defs/X', default: { $ref: '#' }, examples: [{ $ref: '#/bar' }] } }, + required: ['default'] + } + } + } as const; + const WITH_ID = { + $id: 'https://example/x', + type: 'array', + items: { $ref: '#/$defs/D' }, + $defs: { D: { type: 'number' } } + } as const; + const makeServer = (): McpServer => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool('scope', { inputSchema: z.object({}), outputSchema: fromJsonSchema(NATURAL) }, () => ({ + structuredContent: { default: { default: 7 } }, + content: [] + })); + s.registerTool('with-id', { inputSchema: z.object({}), outputSchema: fromJsonSchema(WITH_ID) }, () => ({ + structuredContent: [1], + content: [] + })); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + const { tools } = await client.listTools(); + const wrapped = tools.find(t => t.name === 'scope')?.outputSchema; + expect(wrapped).toEqual({ + type: 'object', + properties: { + result: { + anyOf: [ + { $dynamicRef: '#/properties/result/$defs/X' }, // keyword position — rewritten + { const: { $ref: '#/foo' } } // keyword data position — NOT rewritten + ], + $defs: { + X: { + type: 'object', + properties: { + default: { + // name-position `default` recursed into; its `$ref` rewritten + $ref: '#/properties/result/$defs/X', + default: { $ref: '#' }, // keyword data position — NOT rewritten + examples: [{ $ref: '#/bar' }] // keyword data position — NOT rewritten + } + }, + required: ['default'] + } + } + } + }, + required: ['result'] + }); + // `$id` at the natural root: same-document refs resolve against the embedded base, so the + // embedded schema is wrapped but its `$ref` is NOT rewritten. + expect(tools.find(t => t.name === 'with-id')?.outputSchema).toEqual({ + type: 'object', + properties: { result: WITH_ID }, + required: ['result'] + }); +}); diff --git a/test/e2e/scenarios/mrtr.test.ts b/test/e2e/scenarios/mrtr.test.ts new file mode 100644 index 0000000000..2d8d0a376f --- /dev/null +++ b/test/e2e/scenarios/mrtr.test.ts @@ -0,0 +1,510 @@ +/** + * Multi round-trip requests (SEP-2322, protocol revision 2026-07-28) through + * the public surface: a write-once tool returning inputRequired() is + * fulfilled by the client's registered elicitation handler and retried with + * fresh ids + a byte-exact requestState echo; push-style server→client APIs + * loud-fail on 2026-era requests with the inputRequired() steer; URL-mode + * elicitation rides the flow with zero -32042 on the 2026 wire; the + * auto-fulfilment driver is bounded by inputRequired.maxRounds; and 2025-era + * serving keeps the exact -32042 behavior (the freeze cell). + * + * The 2026-era cells run on the entryModern arm (per-request modern hosting); + * raw wire facts are asserted on the arm-recorded HTTP exchanges. + */ +import { Client, SdkError, SdkErrorCode } from '@modelcontextprotocol/client'; +import { acceptedContent, inputRequired, McpServer, ProtocolError, UrlElicitationRequiredError } 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'; + +/** Every JSON-RPC request the wired client POSTed for the given method, in order. */ +function recordedRequests(wired: Wired, method: string): Array> { + const requests: Array> = []; + for (const exchange of wired.httpLog ?? []) { + if (exchange.requestBody === undefined) continue; + try { + const parsed = JSON.parse(exchange.requestBody) as Record; + if (parsed.method === method) requests.push(parsed); + } catch { + // Not a JSON body (e.g. an empty notification POST) — skip it. + } + } + return requests; +} + +/** All recorded HTTP bytes (request bodies + response bodies) concatenated, for absence assertions. */ +async function allRecordedBytes(wired: Wired): Promise { + const responses = await Promise.all((wired.httpLog ?? []).map(exchange => exchange.response.text())); + const requests = (wired.httpLog ?? []).map(exchange => exchange.requestBody ?? ''); + return [...requests, ...responses].join('\n'); +} + +const CONFIRM_SCHEMA = { type: 'object' as const, properties: { confirm: { type: 'boolean' as const } }, required: ['confirm'] }; + +verifies('typescript:mrtr:tools-call:write-once-roundtrip', async ({ transport }: TestArgs) => { + const makeServer = () => { + const server = new McpServer({ name: 'mrtr-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('deploy', { inputSchema: z.object({ env: z.string() }) }, async ({ env }, ctx) => { + const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + if (!confirmed?.confirm) { + return inputRequired({ + inputRequests: { confirm: inputRequired.elicit({ message: `Deploy to ${env}?`, requestedSchema: CONFIRM_SCHEMA }) }, + requestState: 'opaque-deploy-state' + }); + } + return { content: [{ type: 'text', text: `deployed to ${env}` }] }; + }); + return server; + }; + + const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } }); + const handled: unknown[] = []; + client.setRequestHandler('elicitation/create', async request => { + handled.push(request.params); + return { action: 'accept', content: { confirm: true } }; + }); + + await using wired = await wire(transport, makeServer, client); + + const result = await client.callTool({ name: 'deploy', arguments: { env: 'prod' } }); + expect(result.content).toEqual([{ type: 'text', text: 'deployed to prod' }]); + expect('resultType' in result).toBe(false); + + // The registered handler fulfilled the embedded elicitation. + expect(handled).toHaveLength(1); + expect(handled[0]).toMatchObject({ mode: 'form', message: 'Deploy to prod?' }); + + // Two independent wire legs with fresh ids; the retry carries the bare + // response and the byte-exact requestState echo alongside the original params. + const toolCalls = recordedRequests(wired, 'tools/call'); + expect(toolCalls).toHaveLength(2); + expect(toolCalls[0]!.id).not.toEqual(toolCalls[1]!.id); + const retryParams = toolCalls[1]!.params as Record; + expect(retryParams.name).toBe('deploy'); + expect(retryParams.arguments).toEqual({ env: 'prod' }); + expect(retryParams.requestState).toBe('opaque-deploy-state'); + expect(retryParams.inputResponses).toEqual({ confirm: { action: 'accept', content: { confirm: true } } }); +}); + +verifies('typescript:mrtr:push-api:loud-fail-2026', async ({ transport }: TestArgs) => { + const makeServer = () => { + const server = new McpServer({ name: 'mrtr-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('legacy-style', { inputSchema: z.object({}) }, async (_args, ctx) => { + // The pre-2026 pattern: pushing a server→client elicitation request. + const answer = await ctx.mcpReq.elicitInput({ message: 'Name?', requestedSchema: { type: 'object', properties: {} } }); + return { content: [{ type: 'text', text: JSON.stringify(answer) }] }; + }); + return server; + }; + + const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } }); + client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: {} })); + + await using wired = await wire(transport, makeServer, client); + + const result = await client.callTool({ name: 'legacy-style', arguments: {} }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('inputRequired('); + + // The attempted server→client request never produced wire traffic: no + // elicitation/create request appears in any recorded exchange. + const bytes = await allRecordedBytes(wired); + expect(bytes).not.toContain('"method":"elicitation/create"'); +}); + +verifies('typescript:mrtr:url-elicitation:no-32042-on-2026', async ({ transport }: TestArgs) => { + const makeServer = () => { + const server = new McpServer({ name: 'mrtr-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('protected', { inputSchema: z.object({}) }, async (_args, ctx) => { + if (ctx.mcpReq.inputResponses?.['auth'] !== undefined) { + return { content: [{ type: 'text', text: 'authorized' }] }; + } + // The 2026-07-28 idiom: return an embedded URL-mode elicitation + // (the 2025-style throw is not converted on this era). + return inputRequired({ + inputRequests: { + auth: inputRequired.elicitUrl({ + message: 'Sign in to continue', + url: 'https://example.com/auth' + }) + } + }); + }); + return server; + }; + + const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { elicitation: { url: {} } } }); + const seenUrlRequests: unknown[] = []; + client.setRequestHandler('elicitation/create', async request => { + seenUrlRequests.push(request.params); + // URL mode: the user completes the interaction out of band; the + // response carries no content. + return { action: 'accept' }; + }); + + await using wired = await wire(transport, makeServer, client); + + const result = await client.callTool({ name: 'protected', arguments: {} }); + expect(result.content).toEqual([{ type: 'text', text: 'authorized' }]); + expect(seenUrlRequests).toHaveLength(1); + expect(seenUrlRequests[0]).toMatchObject({ mode: 'url', url: 'https://example.com/auth' }); + + // The -32042 error code never appears on the 2026 wire; the + // input_required result is what travelled instead. + const bytes = await allRecordedBytes(wired); + expect(bytes).not.toContain('32042'); + expect(bytes).toContain('"resultType":"input_required"'); +}); + +verifies('typescript:mrtr:rounds-cap', async ({ transport }: TestArgs) => { + const makeServer = () => { + const server = new McpServer({ name: 'mrtr-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('insatiable', { inputSchema: z.object({}) }, async () => + inputRequired({ + inputRequests: { more: inputRequired.elicit({ message: 'More input?', requestedSchema: CONFIRM_SCHEMA }) }, + requestState: 'never-enough' + }) + ); + return server; + }; + + const client = new Client( + { name: 'mrtr-client', version: '1.0.0' }, + { capabilities: { elicitation: { form: {} } }, inputRequired: { maxRounds: 2 } } + ); + client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { confirm: true } })); + + await using wired = await wire(transport, makeServer, client); + + const outcome = await client.callTool({ name: 'insatiable', arguments: {} }).then( + value => ({ resolved: value as unknown }), + error => ({ rejected: error as unknown }) + ); + expect('rejected' in outcome, 'the call must not resolve').toBe(true); + const rejection = (outcome as { rejected: unknown }).rejected; + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.InputRequiredRoundsExceeded); + expect((rejection as SdkError).data).toMatchObject({ rounds: 2, lastResult: { requestState: 'never-enough' } }); + + // The cap bounded the wire traffic: the original call plus exactly two retries. + expect(recordedRequests(wired, 'tools/call')).toHaveLength(3); +}); + +// 2026-era siblings of the push-style sampling/elicitation/roots round-trips: +// each body returns inputRequired() with an embedded request, the client +// auto-fulfilment driver dispatches it to the locally registered handler, and +// the retried tool handler reads the response from ctx.mcpReq.inputResponses. + +verifies('sampling:mrtr:create:basic', async ({ transport }: TestArgs) => { + const makeServer = () => { + const server = new McpServer({ name: 'mrtr-sampling', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('summarize', { inputSchema: z.object({ text: z.string() }) }, async ({ text }, ctx) => { + const completion = ctx.mcpReq.inputResponses?.['llm'] as + | { role: string; content: { type: string; text: string }; model: string; stopReason: string } + | undefined; + if (completion === undefined) { + return inputRequired({ + inputRequests: { + llm: inputRequired.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: `Summarize: ${text}` } }], + maxTokens: 64 + }) + } + }); + } + return { structuredContent: { completion }, content: [{ type: 'text', text: completion.content.text }] }; + }); + return server; + }; + + const seen: unknown[] = []; + const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { sampling: {} } }); + client.setRequestHandler('sampling/createMessage', async request => { + seen.push(request.params); + return { role: 'assistant', content: { type: 'text', text: 'a brief summary' }, model: 'stub-model', stopReason: 'endTurn' }; + }); + + await using wired = await wire(transport, makeServer, client); + + const result = await client.callTool({ name: 'summarize', arguments: { text: 'hello world' } }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual({ + completion: { role: 'assistant', content: { type: 'text', text: 'a brief summary' }, model: 'stub-model', stopReason: 'endTurn' } + }); + + // The embedded request reached the registered handler exactly once. + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ messages: [{ role: 'user', content: { type: 'text', text: 'Summarize: hello world' } }] }); + + // Two independent wire legs: original + retry carrying the bare response. + const toolCalls = recordedRequests(wired, 'tools/call'); + expect(toolCalls).toHaveLength(2); + const retryParams = toolCalls[1]!.params as Record; + expect(retryParams.inputResponses).toMatchObject({ + llm: { role: 'assistant', content: { type: 'text', text: 'a brief summary' }, model: 'stub-model', stopReason: 'endTurn' } + }); +}); + +verifies( + ['sampling:mrtr:create:model-preferences', 'sampling:mrtr:create:system-prompt', 'sampling:mrtr:create:include-context'], + async ({ transport }: TestArgs) => { + const PREFS = { hints: [{ name: 'stub-model' }], costPriority: 0.2, speedPriority: 0.5, intelligencePriority: 0.9 }; + const makeServer = () => { + const server = new McpServer({ name: 'mrtr-sampling', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('ask', { inputSchema: z.object({}) }, async (_args, ctx) => { + if (ctx.mcpReq.inputResponses?.['llm'] !== undefined) { + return { content: [{ type: 'text', text: 'ok' }] }; + } + return inputRequired({ + inputRequests: { + llm: inputRequired.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], + maxTokens: 16, + systemPrompt: 'You are a terse assistant.', + includeContext: 'none', + modelPreferences: PREFS + }) + } + }); + }); + return server; + }; + + const seen: Array> = []; + const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { sampling: {} } }); + client.setRequestHandler('sampling/createMessage', async request => { + seen.push(request.params as Record); + return { role: 'assistant', content: { type: 'text', text: 'hi' }, model: 'stub-model', stopReason: 'endTurn' }; + }); + + await using _ = await wire(transport, makeServer, client); + + const result = await client.callTool({ name: 'ask', arguments: {} }); + expect(result.isError).toBeFalsy(); + expect(seen).toHaveLength(1); + expect(seen[0]?.systemPrompt).toBe('You are a terse assistant.'); + expect(seen[0]?.includeContext).toBe('none'); + expect(seen[0]?.modelPreferences).toEqual(PREFS); + } +); + +verifies('elicitation:mrtr:form:basic', async ({ transport }: TestArgs) => { + const SCHEMA = { + type: 'object' as const, + properties: { name: { type: 'string' as const, description: 'Your name' } }, + required: ['name'] + }; + const makeServer = () => { + const server = new McpServer({ name: 'mrtr-elicit', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('greet', { inputSchema: z.object({}) }, async (_args, ctx) => { + const answered = acceptedContent<{ name: string }>(ctx.mcpReq.inputResponses, 'who'); + if (answered === undefined) { + return inputRequired({ + inputRequests: { who: inputRequired.elicit({ message: 'What is your name?', requestedSchema: SCHEMA }) } + }); + } + return { content: [{ type: 'text', text: `hello ${answered.name}` }] }; + }); + return server; + }; + + const seen: unknown[] = []; + const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } }); + client.setRequestHandler('elicitation/create', async request => { + seen.push(request.params); + return { action: 'accept', content: { name: 'Ada' } }; + }); + + await using _ = await wire(transport, makeServer, client); + + const result = await client.callTool({ name: 'greet', arguments: {} }); + expect(result.content).toEqual([{ type: 'text', text: 'hello Ada' }]); + + // The embedded request delivered message + schema exactly as sent. + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ mode: 'form', message: 'What is your name?', requestedSchema: SCHEMA }); +}); + +verifies('elicitation:mrtr:form:action:decline', async ({ transport }: TestArgs) => { + const makeServer = () => { + const server = new McpServer({ name: 'mrtr-elicit', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('ask', { inputSchema: z.object({}) }, async (_args, ctx) => { + const responses = ctx.mcpReq.inputResponses; + if (responses === undefined) { + return inputRequired({ + inputRequests: { confirm: inputRequired.elicit({ message: 'Proceed?', requestedSchema: CONFIRM_SCHEMA }) } + }); + } + const raw = responses['confirm'] as { action: string; content?: unknown }; + return { + structuredContent: { action: raw.action, accepted: acceptedContent(responses, 'confirm') !== undefined }, + content: [] + }; + }); + return server; + }; + + const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } }); + client.setRequestHandler('elicitation/create', async () => ({ action: 'decline' })); + + await using _ = await wire(transport, makeServer, client); + + const result = await client.callTool({ name: 'ask', arguments: {} }); + expect(result.structuredContent).toEqual({ action: 'decline', accepted: false }); +}); + +verifies('elicitation:mrtr:form:action:cancel', async ({ transport }: TestArgs) => { + const makeServer = () => { + const server = new McpServer({ name: 'mrtr-elicit', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('ask', { inputSchema: z.object({}) }, async (_args, ctx) => { + const responses = ctx.mcpReq.inputResponses; + if (responses === undefined) { + return inputRequired({ + inputRequests: { confirm: inputRequired.elicit({ message: 'Proceed?', requestedSchema: CONFIRM_SCHEMA }) } + }); + } + const raw = responses['confirm'] as { action: string; content?: unknown }; + return { + structuredContent: { action: raw.action, accepted: acceptedContent(responses, 'confirm') !== undefined }, + content: [] + }; + }); + return server; + }; + + const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } }); + client.setRequestHandler('elicitation/create', async () => ({ action: 'cancel' })); + + await using _ = await wire(transport, makeServer, client); + + const result = await client.callTool({ name: 'ask', arguments: {} }); + expect(result.structuredContent).toEqual({ action: 'cancel', accepted: false }); +}); + +verifies('elicitation:mrtr:form:schema:primitives', async ({ transport }: TestArgs) => { + const SCHEMA = { + type: 'object' as const, + properties: { + email: { type: 'string' as const, format: 'email' as const }, + age: { type: 'integer' as const }, + score: { type: 'number' as const }, + subscribe: { type: 'boolean' as const } + }, + required: ['email'] + }; + const makeServer = () => { + const server = new McpServer({ name: 'mrtr-elicit', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('signup', { inputSchema: z.object({}) }, async (_args, ctx) => { + if (ctx.mcpReq.inputResponses?.['form'] !== undefined) { + return { content: [{ type: 'text', text: 'ok' }] }; + } + return inputRequired({ + inputRequests: { form: inputRequired.elicit({ message: 'Sign up', requestedSchema: SCHEMA }) } + }); + }); + return server; + }; + + const seen: unknown[] = []; + const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } }); + client.setRequestHandler('elicitation/create', async request => { + seen.push(request.params); + return { action: 'accept', content: { email: 'ada@example.com', age: 36, score: 0.9, subscribe: true } }; + }); + + await using _ = await wire(transport, makeServer, client); + + const result = await client.callTool({ name: 'signup', arguments: {} }); + expect(result.isError).toBeFalsy(); + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ requestedSchema: SCHEMA }); +}); + +verifies('roots:mrtr:list:basic', async ({ transport }: TestArgs) => { + const makeServer = () => { + const server = new McpServer({ name: 'mrtr-roots', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('list-roots', { inputSchema: z.object({}) }, async (_args, ctx) => { + const answered = ctx.mcpReq.inputResponses?.['roots'] as { roots: Array<{ uri: string; name?: string }> } | undefined; + if (answered === undefined) { + return inputRequired({ inputRequests: { roots: inputRequired.listRoots() } }); + } + return { structuredContent: { roots: answered.roots }, content: [] }; + }); + return server; + }; + + const seen: Array<{ method: string }> = []; + const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { roots: {} } }); + client.setRequestHandler('roots/list', async request => { + seen.push({ method: request.method }); + return { + roots: [{ uri: 'file:///home/user/projects/myproject', name: 'My Project' }, { uri: 'file:///home/user/repos/backend' }] + }; + }); + + await using _ = await wire(transport, makeServer, client); + + const result = await client.callTool({ name: 'list-roots', arguments: {} }); + expect(result.isError).toBeFalsy(); + expect(seen).toHaveLength(1); + expect(seen[0]?.method).toBe('roots/list'); + expect(result.structuredContent).toEqual({ + roots: [{ uri: 'file:///home/user/projects/myproject', name: 'My Project' }, { uri: 'file:///home/user/repos/backend' }] + }); +}); + +verifies('roots:mrtr:list:empty', async ({ transport }: TestArgs) => { + const makeServer = () => { + const server = new McpServer({ name: 'mrtr-roots', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('list-roots', { inputSchema: z.object({}) }, async (_args, ctx) => { + const answered = ctx.mcpReq.inputResponses?.['roots'] as { roots: unknown[] } | undefined; + if (answered === undefined) { + return inputRequired({ inputRequests: { roots: inputRequired.listRoots() } }); + } + return { structuredContent: { count: answered.roots.length }, content: [] }; + }); + return server; + }; + + const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { roots: {} } }); + client.setRequestHandler('roots/list', async () => ({ roots: [] })); + + await using _ = await wire(transport, makeServer, client); + + const result = await client.callTool({ name: 'list-roots', arguments: {} }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual({ count: 0 }); +}); + +verifies('typescript:mrtr:legacy-32042-freeze', async ({ transport }: TestArgs) => { + const URL_PARAMS = { + mode: 'url' as const, + message: 'Sign in to continue', + elicitationId: 'auth-legacy', + url: 'https://example.com/auth' + }; + const makeServer = () => { + const server = new McpServer({ name: 'legacy-url-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('protected', { inputSchema: z.object({}) }, async () => { + throw new UrlElicitationRequiredError([URL_PARAMS]); + }); + return server; + }; + const client = new Client({ name: 'legacy-url-client', version: '1.0.0' }, { capabilities: { elicitation: { url: {} } } }); + + await using _ = await wire(transport, makeServer, client); + + const outcome = await client.callTool({ name: 'protected', arguments: {} }).then( + value => ({ resolved: value as unknown }), + error => ({ rejected: error as unknown }) + ); + expect('rejected' in outcome, 'the -32042 error must surface, not a result').toBe(true); + const rejection = (outcome as { rejected: unknown }).rejected; + expect(rejection).toBeInstanceOf(ProtocolError); + expect((rejection as ProtocolError).code).toBe(-32_042); + expect((rejection as ProtocolError).data).toEqual({ elicitations: [URL_PARAMS] }); +}); diff --git a/test/e2e/scenarios/pagination.test.ts b/test/e2e/scenarios/pagination.test.ts index e0106494d6..93c522431a 100644 --- a/test/e2e/scenarios/pagination.test.ts +++ b/test/e2e/scenarios/pagination.test.ts @@ -95,19 +95,20 @@ verifies('pagination:client:cursor-handling', async ({ transport }: TestArgs) => await using _ = await wire(transport, makeServer, client); const tap = tapWire(client); - const collectedPages: string[][] = []; - let result = await client.listTools(); - collectedPages.push(result.tools.map(t => t.name)); - while (result.nextCursor !== undefined) { - // A run-away loop means the test fixture, not the SDK, is broken — fail fast instead of hitting the suite timeout. - if (collectedPages.length >= pages.size) throw new Error('nextCursor still present after the last page'); - result = await client.listTools({ cursor: result.nextCursor }); - collectedPages.push(result.tools.map(t => t.name)); - } + // No-arg listTools() auto-aggregates; the client walks the cursor chain on the wire. + const result = await client.listTools(); + expect(result.nextCursor).toBeUndefined(); + expect(result.tools.map(t => t.name)).toEqual([ + 'get_weather', + 'get_forecast', + 'get_alerts', + 'convert_units', + 'list_stations', + 'get_station' + ]); - // The handler got back exactly the cursors it issued, and every page arrived once, in order. + // The handler got back exactly the cursors it issued, once each, in order. expect(receivedCursors).toEqual([undefined, cursorToPage2, cursorToPage3]); - expect(collectedPages).toEqual([['get_weather', 'get_forecast', 'get_alerts'], ['convert_units'], ['list_stations', 'get_station']]); // The wire requests carried the server-issued strings byte-for-byte — opaque, unparsed, unmodified. const wireListRequests = tap.sent.filter(m => isJSONRPCRequest(m)).filter(m => m.method === 'tools/list'); diff --git a/test/e2e/scenarios/prompts.test.ts b/test/e2e/scenarios/prompts.test.ts index b5052b5572..fa941cafe0 100644 --- a/test/e2e/scenarios/prompts.test.ts +++ b/test/e2e/scenarios/prompts.test.ts @@ -128,21 +128,11 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const first = await client.listPrompts(); - expect(first.prompts.length).toBeLessThan(TOTAL); - expect(first.nextCursor).toBeDefined(); - - const seen = new Set(first.prompts.map(p => p.name)); - let result = first; - let pages = 1; - while (result.nextCursor !== undefined) { - result = await client.listPrompts({ cursor: result.nextCursor }); - for (const p of result.prompts) seen.add(p.name); - pages++; - expect(pages).toBeLessThan(50); - } - expect(seen.size).toBe(TOTAL); - expect(pages).toBeGreaterThan(1); + // No-arg listPrompts() auto-aggregates every page. + const all = await client.listPrompts(); + expect(all.prompts.length).toBe(TOTAL); + expect(all.nextCursor).toBeUndefined(); + expect(new Set(all.prompts.map(p => p.name)).size).toBe(TOTAL); }, { title: 'mcpserver' } ); @@ -174,29 +164,20 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const seen = new Set(); - const cursorsSent: string[] = []; - let pages = 0; - let result = await client.listPrompts(); - expect(result.nextCursor).toBeDefined(); - for (;;) { - for (const p of result.prompts) { - expect(seen.has(p.name)).toBe(false); - seen.add(p.name); - } - pages++; - if (result.nextCursor === undefined) break; - cursorsSent.push(result.nextCursor); - result = await client.listPrompts({ cursor: result.nextCursor }); - expect(pages).toBeLessThan(50); - } - - expect(pages).toBe(3); + // No-arg listPrompts() auto-aggregates every page; the server receives + // the cursor walk verbatim (protocol-level pagination is what is + // verified here). + const result = await client.listPrompts(); + expect(result.nextCursor).toBeUndefined(); + const seen = new Set(result.prompts.map(p => p.name)); expect(seen.size).toBe(TOTAL); for (const name of all) expect(seen.has(name)).toBe(true); - expect(cursorsReceived).toEqual([undefined, '10', '20']); - expect(cursorsSent).toEqual(['10', '20']); + + // Explicit cursor → one raw page (per-page path). + const page = await client.listPrompts({ cursor: '10' }); + expect(page.prompts.length).toBe(PAGE); + expect(page.nextCursor).toBe('20'); }, { title: 'raw server' } ); diff --git a/test/e2e/scenarios/protocol.test.ts b/test/e2e/scenarios/protocol.test.ts index 40b5a20af3..9a9b08b9f6 100644 --- a/test/e2e/scenarios/protocol.test.ts +++ b/test/e2e/scenarios/protocol.test.ts @@ -18,7 +18,8 @@ import type { Progress, RequestId, Result, - Transport + Transport, + TransportSendOptions } from '@modelcontextprotocol/server'; import { InMemoryTransport, @@ -127,6 +128,62 @@ verifies('protocol:cancel:abort-signal', async ({ transport }: TestArgs) => { expect(cancelled.params?.reason).toContain('user requested cancellation'); }); +verifies('protocol:cancel:http-stream-close', async ({ transport }: TestArgs) => { + // 2026-07-28 Streamable HTTP: closing the per-request SSE stream IS the + // cancel signal — no notifications/cancelled is sent on the wire. The body + // proves both the caller-signal and timeout paths route to stream-close. + const client = newClient(); + await using _ = await wire(transport, neverRespondingServer, client); + + // Tap send to record outbound messages AND the per-request requestSignal + // the protocol layer hands the transport. + const sent: Array<{ m: JSONRPCMessage; opts: TransportSendOptions | undefined }> = []; + const tx = client.transport; + if (!tx) throw new Error('client not connected'); + expect(tx.hasPerRequestStream).toBe(true); + const orig = tx.send.bind(tx); + tx.send = async (m, opts) => { + sent.push({ m, opts }); + return orig(m, opts); + }; + + // Caller-signal abort. + const ac = new AbortController(); + const call = client.listTools(undefined, { signal: ac.signal }); + await vi.waitFor(() => expect(sent.some(s => isRequest(s.m) && s.m.method === 'tools/list')).toBe(true)); + const listSend = sent.find(s => isRequest(s.m) && s.m.method === 'tools/list'); + if (!listSend) throw new Error('tools/list send not captured'); + expect(listSend.opts?.requestSignal, 'protocol layer must thread a per-request requestSignal on a 2026 HTTP connection').toBeInstanceOf( + AbortSignal + ); + expect(listSend.opts?.requestSignal?.aborted).toBe(false); + + ac.abort('user requested cancellation'); + await expect(call).rejects.toThrow(/user requested cancellation/); + + expect(listSend.opts?.requestSignal?.aborted, 'stream-close IS the cancel signal: requestSignal must be aborted').toBe(true); + expect( + sent.filter(s => isNotification(s.m) && s.m.method === 'notifications/cancelled'), + 'no notifications/cancelled on the wire — "not required or expected" per spec' + ).toHaveLength(0); + + // Timeout path. + sent.length = 0; + vi.useFakeTimers(); + try { + const pending = client.listTools(undefined, { timeout: 100 }); + pending.catch(() => {}); + await vi.advanceTimersByTimeAsync(100); + await expect(pending).rejects.toMatchObject({ code: SdkErrorCode.RequestTimeout }); + } finally { + vi.useRealTimers(); + } + const timedOutSend = sent.find(s => isRequest(s.m) && s.m.method === 'tools/list'); + if (!timedOutSend) throw new Error('timeout-path tools/list send not captured'); + expect(timedOutSend.opts?.requestSignal?.aborted).toBe(true); + expect(sent.filter(s => isNotification(s.m) && s.m.method === 'notifications/cancelled')).toHaveLength(0); +}); + verifies('protocol:cancel:handler-abort-propagates', async ({ transport }: TestArgs) => { const aborts: Array<{ requestId: RequestId; reason: unknown }> = []; const makeServer = () => { @@ -365,8 +422,11 @@ verifies('protocol:error:invalid-params', async ({ transport }: TestArgs) => { await expect(call).rejects.toBeInstanceOf(ProtocolError); // The malformed request did reach the wire (failure is server-side, not client-side validation). + // toMatchObject: on a 2026-07-28 connection the client auto-attaches the per-request `_meta` + // envelope, which is additive and not part of the assertion's intent. const sent = outbound.filter(m => isRequest(m)).find(m => m.method === 'tools/call'); - expect(sent?.params).toEqual({ arguments: {} }); + expect(sent?.params).toMatchObject({ arguments: {} }); + expect((sent?.params as { name?: unknown }).name).toBeUndefined(); expect(ProtocolErrorCode.InvalidParams).toBe(-32_602); await expect(call).rejects.toMatchObject({ code: ProtocolErrorCode.InvalidParams }); @@ -1535,16 +1595,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 +1644,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..2307336980 --- /dev/null +++ b/test/e2e/scenarios/raw-result-type.test.ts @@ -0,0 +1,181 @@ +/** + * 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. Auto-fulfilment is disabled here so this requirement keeps + // proving the discrimination surface itself (the typed local error); the + // multi-round-trip driver has its own requirements (typescript:mrtr:*). ---- + { + const client = new Client( + { name: 'raw-result-type-client', version: '0' }, + { versionNegotiation: { mode: { pin: '2026-07-28' } }, inputRequired: { autoFulfill: false } } + ); + 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/resources.test.ts b/test/e2e/scenarios/resources.test.ts index ea83696915..58f7b8b1a6 100644 --- a/test/e2e/scenarios/resources.test.ts +++ b/test/e2e/scenarios/resources.test.ts @@ -78,21 +78,11 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const first = await client.listResources(); - expect(first.resources.length).toBeLessThan(TOTAL); - expect(first.nextCursor).toBeDefined(); - - const seen = new Set(first.resources.map(r => r.uri)); - let result = first; - let pages = 1; - while (result.nextCursor !== undefined) { - result = await client.listResources({ cursor: result.nextCursor }); - for (const r of result.resources) seen.add(r.uri); - pages++; - expect(pages).toBeLessThan(50); - } - expect(seen.size).toBe(TOTAL); - expect(pages).toBeGreaterThan(1); + // No-arg listResources() auto-aggregates every page. + const all = await client.listResources(); + expect(all.resources.length).toBe(TOTAL); + expect(all.nextCursor).toBeUndefined(); + expect(new Set(all.resources.map(r => r.uri)).size).toBe(TOTAL); }, { title: 'mcpserver' } ); @@ -122,30 +112,20 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const seen = new Set(); - const cursorsSent: string[] = []; - let pages = 0; - let result = await client.listResources(); - expect(result.nextCursor).toBeDefined(); - for (;;) { - for (const r of result.resources) { - expect(seen.has(r.uri)).toBe(false); - seen.add(r.uri); - } - pages++; - if (result.nextCursor === undefined) break; - cursorsSent.push(result.nextCursor); - result = await client.listResources({ cursor: result.nextCursor }); - expect(pages).toBeLessThan(50); - } - + // No-arg listResources() auto-aggregates every page; the server + // receives the cursor walk verbatim (protocol-level pagination is + // what is verified here). + const result = await client.listResources(); expect(result.nextCursor).toBeUndefined(); - expect(pages).toBe(3); + const seen = new Set(result.resources.map(r => r.uri)); expect(seen.size).toBe(TOTAL); for (const name of all) expect(seen.has(name)).toBe(true); - expect(cursorsReceived).toEqual([undefined, '10', '20']); - expect(cursorsSent).toEqual(['10', '20']); + + // Explicit cursor → one raw page (per-page path). + const page = await client.listResources({ cursor: '10' }); + expect(page.resources.length).toBe(PAGE); + expect(page.nextCursor).toBe('20'); }, { title: 'raw server' } ); @@ -202,10 +182,32 @@ verifies('resources:read:unknown-uri', async ({ transport }: TestArgs) => { const client = newClient(); await using _ = await wire(transport, makeServer, client); - await expect(client.readResource({ uri: 'file:///no-such-resource' })).rejects.toMatchObject({ - code: -32_002, - message: expect.stringMatching(/not found|unknown/i) - }); + let received: ProtocolError | undefined; + try { + const result = await client.readResource({ uri: 'file:///no-such-resource' }); + // MUST-NOT rider: never an empty contents array for a non-existent resource. + expect(result.contents).not.toEqual([]); + } catch (error) { + received = error as ProtocolError; + } + expect(received).toBeDefined(); + + // The wire code is −32602 on every protocol revision (the encode seam owns + // the −32002 → −32602 mapping), with `data.uri` echoing the requested URI. + expect(received!.code).toBe(-32_602); + expect(received!.message).toMatch(/not found/i); + expect(received!.data).toEqual({ uri: 'file:///no-such-resource' }); + + // The cross-bundle data-parse recognizer reconstructs the typed error + // from code + structurally valid data (no `instanceof` across bundles). + // It accepts BOTH −32602 and the legacy −32002; the duck shape is `data.uri`. + const recognised = ProtocolError.fromError(received!.code, received!.message, received!.data); + expect((recognised as { uri?: string }).uri).toBe('file:///no-such-resource'); + const legacy = ProtocolError.fromError(-32_002, 'Resource not found', { uri: 'file:///x' }); + expect((legacy as { uri?: string }).uri).toBe('file:///x'); + // ProtocolErrorCode.ResourceNotFound (−32002) stays importable as legacy + // receive-tolerated vocabulary. + expect(ProtocolErrorCode.ResourceNotFound).toBe(-32_002); }); verifies('resources:read:template-vars', async ({ transport }: TestArgs) => { @@ -498,21 +500,11 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const first = await client.listResourceTemplates(); - expect(first.resourceTemplates.length).toBeLessThan(TOTAL); - expect(first.nextCursor).toBeDefined(); - - const seen = new Set(first.resourceTemplates.map(t => t.uriTemplate)); - let result = first; - let pages = 1; - while (result.nextCursor !== undefined) { - result = await client.listResourceTemplates({ cursor: result.nextCursor }); - for (const t of result.resourceTemplates) seen.add(t.uriTemplate); - pages++; - expect(pages).toBeLessThan(50); - } - expect(seen.size).toBe(TOTAL); - expect(pages).toBeGreaterThan(1); + // No-arg listResourceTemplates() auto-aggregates every page. + const all = await client.listResourceTemplates(); + expect(all.resourceTemplates.length).toBe(TOTAL); + expect(all.nextCursor).toBeUndefined(); + expect(new Set(all.resourceTemplates.map(t => t.uriTemplate)).size).toBe(TOTAL); }, { title: 'mcpserver' } ); @@ -539,25 +531,17 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const seen = new Set(); - let pages = 0; - let result = await client.listResourceTemplates(); - expect(result.nextCursor).toBeDefined(); - for (;;) { - for (const t of result.resourceTemplates) { - expect(seen.has(t.uriTemplate)).toBe(false); - seen.add(t.uriTemplate); - } - pages++; - if (result.nextCursor === undefined) break; - result = await client.listResourceTemplates({ cursor: result.nextCursor }); - expect(pages).toBeLessThan(50); - } - + // No-arg listResourceTemplates() auto-aggregates every page. + const result = await client.listResourceTemplates(); expect(result.nextCursor).toBeUndefined(); - expect(pages).toBe(3); + const seen = new Set(result.resourceTemplates.map(t => t.uriTemplate)); expect(seen.size).toBe(TOTAL); for (const name of all) expect(seen.has(name)).toBe(true); + + // Explicit cursor → one raw page (per-page path). + const page = await client.listResourceTemplates({ cursor: '10' }); + expect(page.resourceTemplates.length).toBe(PAGE); + expect(page.nextCursor).toBe('20'); }, { title: 'raw server' } ); diff --git a/test/e2e/scenarios/sampling.test.ts b/test/e2e/scenarios/sampling.test.ts index f251a9ef5f..75e4fc52cf 100644 --- a/test/e2e/scenarios/sampling.test.ts +++ b/test/e2e/scenarios/sampling.test.ts @@ -202,7 +202,7 @@ verifies('sampling:error:user-rejected', async ({ transport }: TestArgs) => { const r = await client.callTool({ name: 'sampling-passthrough', arguments: { messages: [], maxTokens: 10 } }); expect(r.structuredContent).toMatchObject({ ok: false, code: -1 }); - expect(r.structuredContent?.message).toMatch(/User rejected sampling request/); + expect((r.structuredContent as { message?: unknown }).message).toMatch(/User rejected sampling request/); }); verifies('sampling:message:content-cardinality', async ({ transport }: TestArgs) => { @@ -340,7 +340,7 @@ verifies('sampling:tool-result:no-mixed-content', async ({ transport }: TestArgs }); expect(r.structuredContent).toMatchObject({ ok: false, code: ProtocolErrorCode.InvalidParams }); - expect(r.structuredContent?.message).toMatch(/tool.?result/i); + expect((r.structuredContent as { message?: unknown }).message).toMatch(/tool.?result/i); }); verifies('sampling:tool-use:result-balance', async ({ transport }: TestArgs) => { @@ -425,7 +425,7 @@ verifies('sampling:tools:server-gated-by-capability', async ({ transport }: Test }); expect(withTools.structuredContent).toMatchObject({ ok: false }); - expect(withTools.structuredContent?.message).toMatch(/sampling.*tools/i); + expect((withTools.structuredContent as { message?: unknown }).message).toMatch(/sampling.*tools/i); expect(received).toHaveLength(0); const withChoice = await client.callTool({ @@ -434,7 +434,7 @@ verifies('sampling:tools:server-gated-by-capability', async ({ transport }: Test }); expect(withChoice.structuredContent).toMatchObject({ ok: false }); - expect(withChoice.structuredContent?.message).toMatch(/sampling.*tools/i); + expect((withChoice.structuredContent as { message?: unknown }).message).toMatch(/sampling.*tools/i); expect(received).toHaveLength(0); const empty = await client.callTool({ @@ -443,7 +443,7 @@ verifies('sampling:tools:server-gated-by-capability', async ({ transport }: Test }); expect(empty.structuredContent).toMatchObject({ ok: false }); - expect(empty.structuredContent?.message).toMatch(/sampling.*tools/i); + expect((empty.structuredContent as { message?: unknown }).message).toMatch(/sampling.*tools/i); expect(received).toHaveLength(0); }); diff --git a/test/e2e/scenarios/sep2243.test.ts b/test/e2e/scenarios/sep2243.test.ts new file mode 100644 index 0000000000..23aba54c22 --- /dev/null +++ b/test/e2e/scenarios/sep2243.test.ts @@ -0,0 +1,91 @@ +/** + * SEP-2243 request-metadata headers (protocol revision 2026-07-28). + * + * End-to-end cells for the SEP-2243 header families over the dual-era HTTP + * entry (`createMcpHandler`), exercised on the wire() `entryModern` arm so the + * raw HTTP request headers are observable on the arm-recorded `wired.httpLog`. + */ +import { Client } from '@modelcontextprotocol/client'; +import { encodeMcpParamValue, MCP_PARAM_HEADER_PREFIX } from '@modelcontextprotocol/core'; +import { fromJsonSchema, McpServer } from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; + +import { modernEnvelopeMeta, wire } from '../helpers/index.js'; +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +/** + * One tool with a single `x-mcp-header`-declared string parameter. Declared as + * a non-literal const so the JSON-Schema vendor extension key passes excess + * property checking on `fromJsonSchema`'s `JSONSchema.Interface` parameter. + */ +const LOCATE_INPUT_SCHEMA = { + type: 'object', + properties: { region: { type: 'string', 'x-mcp-header': 'Region' } }, + required: ['region'] +}; + +verifies('sep-2243:param-header:roundtrip', async ({ transport }: TestArgs) => { + // The server is built by createMcpHandler per request, so its pre-dispatch + // Mcp-Param-* validation runs against this schema. + const makeServer = () => { + const server = new McpServer({ name: 'e2e-sep2243', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('locate', { inputSchema: fromJsonSchema<{ region: string }>(LOCATE_INPUT_SCHEMA) }, ({ region }) => ({ + content: [{ type: 'text', text: `region=${region}` }] + })); + return server; + }; + const client = new Client({ name: 'sep2243-client', version: '1.0.0' }); + await using wired = await wire(transport, makeServer, client); + + // listTools() auto-aggregates and writes the response cache; callTool + // reads it directly and emits the header on its first attempt (the + // spec's 5-step client algorithm). + await client.listTools(); + const result = await client.callTool({ name: 'locate', arguments: { region: 'us-west1' } }); + + // The tools/call HTTP request carries the Mcp-Param-Region header, + // encoded per the SEP-2243 value-encoding rules (a safe ASCII token + // passes through unchanged). + const callExchange = (wired.httpLog ?? []).find(exchange => exchange.requestBody?.includes('"tools/call"')); + expect(callExchange).toBeDefined(); + const headerValue = callExchange!.requestHeaders.get(`${MCP_PARAM_HEADER_PREFIX}Region`); + expect(headerValue).toBe(encodeMcpParamValue('us-west1')); + expect(headerValue).toBe('us-west1'); + + // The call succeeded against the validating server (header agreed with + // the body argument, so no -32020 HeaderMismatch on the wire). + expect(result.isError).toBeFalsy(); + expect(result.content).toEqual([{ type: 'text', text: 'region=us-west1' }]); +}); + +verifies('sep-2243:std-header:mismatch-rejected', async ({ transport }: TestArgs) => { + const makeServer = () => new McpServer({ name: 'e2e-sep2243-std', version: '1.0.0' }, { capabilities: { tools: {} } }); + const client = new Client({ name: 'sep2243-std-client', version: '1.0.0' }); + await using wired = await wire(transport, makeServer, client); + + // Raw POST through the harness-hosted entry: the body is a valid + // envelope-carrying tools/call, but the Mcp-Method header names + // tools/list. The era-classification rung answers the disagreement + // before any factory instance is constructed. + const response = await wired.fetch!(wired.url!, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-method': 'tools/list' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'locate', arguments: {}, _meta: modernEnvelopeMeta({ name: 'sep2243-std-client', version: '1.0.0' }) } + }) + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: number; message: string } }; + // -32020 is the SEP-2243 HeaderMismatch code (post-spec#2907 renumber). + expect(body.error.code).toBe(-32_020); + expect(body.error.message).toMatch(/Mcp-Method/); +}); 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..503540454c --- /dev/null +++ b/test/e2e/scenarios/stdio-dual-era.test.ts @@ -0,0 +1,80 @@ +/** + * 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` (the connection-pinned `serveStdio` + * entry over an ordinary McpServer factory) 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 opens the connection — a plain 2025 client over `initialize`, + * or the auto-negotiating client reaching 2026-07-28 over `server/discover` — + * and the entry pins that connection's instance to the era the client opened + * with. + */ + +import { fileURLToPath } from 'node:url'; + +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +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)); + +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 opens with initialize and the entry + // pins the connection to a 2025-era instance, served exactly as a + // hand-wired stdio server serves it today. + 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), the entry + // pins the connection to a 2026-era instance, 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 result = await client.callTool({ name: 'echo', arguments: { text: 'modern leg' } }); + expect(result.content).toEqual([{ type: 'text', text: 'modern leg' }]); + } finally { + await client.close(); + await transport.close(); + } +}); diff --git a/test/e2e/scenarios/subscriptions.test.ts b/test/e2e/scenarios/subscriptions.test.ts new file mode 100644 index 0000000000..0fb481d724 --- /dev/null +++ b/test/e2e/scenarios/subscriptions.test.ts @@ -0,0 +1,292 @@ +/** + * `subscriptions/listen` (SEP-1865, protocol revision 2026-07-28) through the + * public surface: ack-first, subscription-id stamping, per-stream filtering, + * the listChanged auto-open bridge, and the F-12 legacy steer. + * + * The 2026-era cells host `createMcpHandler` themselves (the test publishes + * via `handler.notify.*`); the legacy cell runs on the standard arms. + */ +import { Client, SdkError, SdkErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { createMcpHandler, McpServer, SUBSCRIPTION_ID_META_KEY } 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'; + +function makeServer() { + const server = new McpServer({ name: 'subs-e2e', version: '1' }); + server.registerTool('greet', { inputSchema: z.object({}) }, async () => ({ content: [] })); + return server; +} + +/** + * A modern in-process host with a tool, a prompt, and a resource registered so + * the entry advertises listChanged for all three kinds (the listen ack honors a + * filter only for kinds the server advertises). + */ +async function hostListenAllKinds() { + const factory = () => { + const server = new McpServer({ name: 'subs-e2e', version: '1' }); + server.registerTool('greet', { inputSchema: z.object({}) }, async () => ({ content: [] })); + server.registerPrompt('hello', { description: 'p' }, async () => ({ messages: [] })); + server.registerResource('r', 'file:///r', {}, async uri => ({ contents: [{ uri: uri.href, text: 'r' }] })); + return server; + }; + const handler = createMcpHandler(factory, { legacy: 'reject', keepAliveMs: 0 }); + const fetch = (u: URL | string, init?: RequestInit) => handler.fetch(new Request(u, init)); + const client = new Client({ name: 'subs-e2e-client', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch })); + expect(client.getNegotiatedProtocolVersion()).toBe('2026-07-28'); + return { + client, + handler, + factory, + fetch, + [Symbol.asyncDispose]: () => Promise.all([client.close(), handler.close()]).then(() => {}) + }; +} + +async function hostListen() { + const handler = createMcpHandler(() => makeServer(), { legacy: 'reject', keepAliveMs: 0 }); + const url = new URL('http://in-process/mcp'); + const fetch = (u: URL | string, init?: RequestInit) => handler.fetch(new Request(u, init)); + const client = new Client({ name: 'subs-e2e-client', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(url, { fetch })); + expect(client.getNegotiatedProtocolVersion()).toBe('2026-07-28'); + return { + client, + handler, + fetch, + url, + [Symbol.asyncDispose]: () => Promise.all([client.close(), handler.close()]).then(() => {}) + }; +} + +verifies('subscriptions:listen:ack-first-stamped', async () => { + const handler = createMcpHandler(() => makeServer(), { legacy: 'reject', keepAliveMs: 0 }); + const response = await handler.fetch( + new Request('http://in-process/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'sub-1', + method: 'subscriptions/listen', + params: { _meta: modernEnvelopeMeta(), notifications: { toolsListChanged: true } } + }) + }) + ); + expect(response.headers.get('Content-Type')).toBe('text/event-stream'); + const reader = response.body!.getReader(); + const { value } = await reader.read(); + const frame = new TextDecoder().decode(value); + const ack = JSON.parse(frame.slice(frame.indexOf('data: ') + 6, frame.indexOf('\n\n'))) as { + method: string; + params: { _meta: Record; notifications: unknown }; + }; + expect(ack.method).toBe('notifications/subscriptions/acknowledged'); + expect(ack.params._meta[SUBSCRIPTION_ID_META_KEY]).toBe('sub-1'); + expect(ack.params.notifications).toEqual({ toolsListChanged: true }); + await reader.cancel(); + await handler.close(); +}); + +verifies('subscriptions:listen:graceful-close', async () => { + // Hosted directly so the test owns handler.close(); `await using` of + // hostListen() would close on dispose and obscure the assertion. + const handler = createMcpHandler(() => makeServer(), { legacy: 'reject', keepAliveMs: 0 }); + const fetch = (u: URL | string, init?: RequestInit) => handler.fetch(new Request(u, init)); + const client = new Client({ name: 'subs-e2e-client', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch })); + const sub = await client.listen({ toolsListChanged: true }); + // Server-side graceful close: the entry's listen router emits the empty + // SubscriptionsListenResult as the final SSE frame, then closes the + // stream. The client surfaces this as `closed: 'graceful'` (distinct from + // `'remote'`, which is the transport-drop / no-result path). + await handler.close(); + await expect(sub.closed).resolves.toBe('graceful'); + await client.close(); +}); + +verifies('subscriptions:listen:per-stream-filter', async () => { + await using h = await hostListen(); + const seen: string[] = []; + h.client.setNotificationHandler('notifications/tools/list_changed', () => void seen.push('tools')); + h.client.setNotificationHandler('notifications/prompts/list_changed', () => void seen.push('prompts')); + const sub = await h.client.listen({ toolsListChanged: true }); + h.handler.notify.promptsChanged(); + h.handler.notify.toolsChanged(); + await new Promise(r => setTimeout(r, 30)); + // The un-requested type was provably never delivered. + expect(seen).toEqual(['tools']); + await sub.close(); +}); + +verifies('typescript:subscriptions:listChanged-auto-open-modern', async () => { + const handler = createMcpHandler(() => makeServer(), { legacy: 'reject', keepAliveMs: 0 }); + const fetch = (u: URL | string, init?: RequestInit) => handler.fetch(new Request(u, init)); + let count = 0; + let done!: () => void; + const finished = new Promise(r => { + done = r; + }); + const client = new Client( + { name: 'subs-e2e-client', version: '1' }, + { + versionNegotiation: { mode: 'auto' }, + listChanged: { tools: { autoRefresh: false, onChanged: () => (++count >= 1 ? done() : undefined) } } + } + ); + await client.connect(new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch })); + expect(client.autoOpenedSubscription?.honoredFilter).toEqual({ toolsListChanged: true }); + handler.notify.toolsChanged(); + await finished; + expect(count).toBe(1); + await client.autoOpenedSubscription!.close(); + await client.close(); + await handler.close(); +}); + +verifies('typescript:subscriptions:listen:legacy-era-steer', async ({ transport }: TestArgs) => { + const client = new Client({ name: 'c', version: '0' }); + await using _ = await wire(transport, makeServer, client); + const error = await client.listen({ toolsListChanged: true }).catch(error_ => error_ as SdkError); + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + expect((error as SdkError).message).toContain('resources/subscribe'); +}); + +verifies('subscriptions:listen:honored-filter-narrows-to-advertised', async () => { + // makeServer registers a tool but no prompts/resources: a listen requesting + // toolsListChanged + promptsListChanged + resourcesListChanged must come + // back honored as toolsListChanged only — the ack reflects only what the + // server advertises. + await using h = await hostListen(); + const sub = await h.client.listen({ toolsListChanged: true, promptsListChanged: true, resourcesListChanged: true }); + expect(sub.honoredFilter).toEqual({ toolsListChanged: true }); + // And nothing the server doesn't advertise reaches the stream: the entry + // delivers via the same narrowed filter it acknowledged. + const seen: string[] = []; + h.client.setNotificationHandler('notifications/prompts/list_changed', () => void seen.push('prompts')); + h.client.setNotificationHandler('notifications/tools/list_changed', () => void seen.push('tools')); + h.handler.notify.promptsChanged(); + h.handler.notify.toolsChanged(); + await new Promise(r => setTimeout(r, 30)); + expect(seen).toEqual(['tools']); + await sub.close(); +}); + +// 2026-era siblings of the captured-instance list_changed publish rows: the +// publication path is handler.notify.* and delivery rides subscriptions/listen. + +verifies('tools:listen:list-changed', async () => { + await using h = await hostListenAllKinds(); + const seen: string[] = []; + h.client.setNotificationHandler('notifications/tools/list_changed', () => void seen.push('tools')); + const sub = await h.client.listen({ toolsListChanged: true }); + expect(sub.honoredFilter).toEqual({ toolsListChanged: true }); + h.handler.notify.toolsChanged(); + await new Promise(r => setTimeout(r, 30)); + expect(seen).toEqual(['tools']); + await sub.close(); +}); + +verifies('resources:listen:list-changed', async () => { + await using h = await hostListenAllKinds(); + const seen: string[] = []; + h.client.setNotificationHandler('notifications/resources/list_changed', () => void seen.push('resources')); + const sub = await h.client.listen({ resourcesListChanged: true }); + expect(sub.honoredFilter).toEqual({ resourcesListChanged: true }); + h.handler.notify.resourcesChanged(); + await new Promise(r => setTimeout(r, 30)); + expect(seen).toEqual(['resources']); + await sub.close(); +}); + +verifies('prompts:listen:list-changed', async () => { + await using h = await hostListenAllKinds(); + const seen: string[] = []; + h.client.setNotificationHandler('notifications/prompts/list_changed', () => void seen.push('prompts')); + const sub = await h.client.listen({ promptsListChanged: true }); + expect(sub.honoredFilter).toEqual({ promptsListChanged: true }); + h.handler.notify.promptsChanged(); + await new Promise(r => setTimeout(r, 30)); + expect(seen).toEqual(['prompts']); + await sub.close(); +}); + +verifies('client:listen:auto-refresh', async () => { + const factory = () => { + const server = new McpServer({ name: 'subs-e2e', version: '1' }); + server.registerTool('greet', { inputSchema: z.object({}) }, async () => ({ content: [] })); + return server; + }; + const handler = createMcpHandler(factory, { legacy: 'reject', keepAliveMs: 0 }); + const fetch = (u: URL | string, init?: RequestInit) => handler.fetch(new Request(u, init)); + let done!: () => void; + const finished = new Promise(r => { + done = r; + }); + const refreshed: unknown[] = []; + const client = new Client( + { name: 'subs-e2e-client', version: '1' }, + { + versionNegotiation: { mode: 'auto' }, + listChanged: { + tools: { + onChanged: (error, tools) => { + expect(error).toBeNull(); + refreshed.push(tools); + done(); + } + } + } + } + ); + await client.connect(new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch })); + expect(client.autoOpenedSubscription?.honoredFilter).toEqual({ toolsListChanged: true }); + handler.notify.toolsChanged(); + await finished; + // The auto-refresh re-fetched tools/list and delivered the fresh result. + expect(refreshed).toHaveLength(1); + expect(refreshed[0]).toEqual([expect.objectContaining({ name: 'greet' })]); + await client.autoOpenedSubscription!.close(); + await client.close(); + await handler.close(); +}); + +verifies('subscriptions:listen:capacity-guard', async () => { + const handler = createMcpHandler(() => makeServer(), { legacy: 'reject', keepAliveMs: 0, maxSubscriptions: 1 }); + const post = (id: number) => + handler.fetch( + new Request('http://in-process/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id, + method: 'subscriptions/listen', + params: { _meta: modernEnvelopeMeta(), notifications: {} } + }) + }) + ); + const first = await post(1); + expect(first.headers.get('Content-Type')).toBe('text/event-stream'); + const second = await post(2); + expect(second.headers.get('Content-Type')).toContain('application/json'); + const body = (await second.json()) as { error: { code: number; message: string } }; + expect(body.error.code).toBe(-32_603); + expect(body.error.message).toBe('Subscription limit reached'); + await first.body!.cancel(); + await handler.close(); +}); diff --git a/test/e2e/scenarios/tools.test.ts b/test/e2e/scenarios/tools.test.ts index 408712f23a..cd840535e2 100644 --- a/test/e2e/scenarios/tools.test.ts +++ b/test/e2e/scenarios/tools.test.ts @@ -758,21 +758,11 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const first = await client.listTools(); - expect(first.tools.length).toBeLessThan(TOTAL); - expect(first.nextCursor).toBeDefined(); - - const seen = new Set(first.tools.map(t => t.name)); - let result = first; - let pages = 1; - while (result.nextCursor !== undefined) { - result = await client.listTools({ cursor: result.nextCursor }); - for (const t of result.tools) seen.add(t.name); - pages++; - expect(pages).toBeLessThan(50); - } - expect(seen.size).toBe(TOTAL); - expect(pages).toBeGreaterThan(1); + // No-arg listTools() auto-aggregates every page. + const all = await client.listTools(); + expect(all.tools.length).toBe(TOTAL); + expect(all.nextCursor).toBeUndefined(); + expect(new Set(all.tools.map(t => t.name)).size).toBe(TOTAL); }, { title: 'mcpserver' } ); @@ -803,30 +793,20 @@ verifies( const client = newClient(); await using _ = await wire(transport, makeServer, client); - const seen = new Set(); - const cursorsSent: string[] = []; - let pages = 0; - let result = await client.listTools(); - expect(result.nextCursor).toBeDefined(); - for (;;) { - for (const t of result.tools) { - expect(seen.has(t.name)).toBe(false); - seen.add(t.name); - } - pages++; - if (result.nextCursor === undefined) break; - cursorsSent.push(result.nextCursor); - result = await client.listTools({ cursor: result.nextCursor }); - expect(pages).toBeLessThan(50); - } - - expect(pages).toBeGreaterThan(1); + // No-arg listTools() auto-aggregates every page; the server receives + // the cursor walk verbatim (protocol-level pagination is what is + // verified here). + const result = await client.listTools(); + expect(result.nextCursor).toBeUndefined(); + const seen = new Set(result.tools.map(t => t.name)); + expect(seen.size).toBe(TOTAL); for (const name of all) expect(seen.has(name)).toBe(true); + expect(cursorsReceived).toEqual([undefined, '10', '20']); - // SDK plumbing: the server's request handler saw the cursors verbatim. - expect(cursorsReceived).toHaveLength(pages); - expect(cursorsReceived[0]).toBeUndefined(); - expect(cursorsReceived.slice(1)).toEqual(cursorsSent); + // Explicit cursor → one raw page (per-page path). + const page = await client.listTools({ cursor: '10' }); + expect(page.tools.length).toBe(PAGE); + expect(page.nextCursor).toBe('20'); }, { title: 'raw server' } ); diff --git a/test/e2e/scenarios/transport-http.test.ts b/test/e2e/scenarios/transport-http.test.ts index b0bd8abbd8..26c7bce868 100644 --- a/test/e2e/scenarios/transport-http.test.ts +++ b/test/e2e/scenarios/transport-http.test.ts @@ -30,7 +30,7 @@ import { expect, vi } from 'vitest'; import { z } from 'zod/v4'; import type { HttpHandler } from '../helpers/index.js'; -import { hostPerSession, hostStateless } from '../helpers/index.js'; +import { defined, hostPerSession, hostStateless } from '../helpers/index.js'; import { verifies } from '../helpers/verifies.js'; import type { TestArgs } from '../types.js'; @@ -74,12 +74,6 @@ function recordingFetch( }; } -/** Narrows away `undefined` for values the surrounding test has already proven exist (replaces non-null assertions). */ -function defined(value: T | undefined, label: string): T { - if (value === undefined) throw new Error(`expected ${label} to be defined`); - return value; -} - verifies('client-transport:http:session-stored', async (_args: TestArgs) => { const records: RecordedRequest[] = []; const handle = hostPerSession(() => echoServer()); diff --git a/test/e2e/scenarios/transport-raw.test.ts b/test/e2e/scenarios/transport-raw.test.ts index 5645df0181..6848efafd2 100644 --- a/test/e2e/scenarios/transport-raw.test.ts +++ b/test/e2e/scenarios/transport-raw.test.ts @@ -17,13 +17,18 @@ 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'; import { z } from 'zod/v4'; -import { hostPerSession, hostStateless } from '../helpers/index.js'; +import { defined, hostPerSession, hostStateless } from '../helpers/index.js'; import { verifies } from '../helpers/verifies.js'; import type { TestArgs } from '../types.js'; @@ -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' } } }; @@ -58,12 +74,6 @@ function echoServer(): McpServer { return s; } -/** Narrows away `undefined` for values the surrounding test has already proven exist (replaces non-null assertions). */ -function defined(value: T | undefined, label: string): T { - if (value === undefined) throw new Error(`expected ${label} to be defined`); - return value; -} - /** Asserts the message observed via onmessage is the initialize response for `id` with the negotiated version. */ function expectInitializeResponse(message: JSONRPCMessage, id: number, protocolVersion: string, serverName: string): void { const response = JSONRPCResultResponseSchema.parse(message); @@ -158,7 +168,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 +221,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/scenarios/validation.test.ts b/test/e2e/scenarios/validation.test.ts index 21121240e7..c1470acd3e 100644 --- a/test/e2e/scenarios/validation.test.ts +++ b/test/e2e/scenarios/validation.test.ts @@ -149,14 +149,18 @@ verifies('validation:pluggable-provider', async ({ transport }: TestArgs) => { await client.listTools(); - // The custom provider compiled the advertised outputSchema (once per tool - // that declares one — both forecast tools share the same schema). - expect(recorder.compiledSchemas).toEqual([FORECAST_OUTPUT_SCHEMA, FORECAST_OUTPUT_SCHEMA]); + // Derived-view behavior: the validator index is compiled lazily on the + // first callTool against the cached tools/list entry's stamp, not eagerly + // at listTools time. + expect(recorder.compiledSchemas).toEqual([]); // The custom provider's validator is the one consulted on tools/call, and - // its (delegated) verdict is what the caller sees. + // its (delegated) verdict is what the caller sees. The first call + // re-derives the whole name → validator index (once per tool that + // declares an outputSchema — both forecast tools share the same schema). const result = await client.callTool({ name: 'forecast', arguments: {} }); expect(result.structuredContent).toEqual({ celsius: 21, summary: 'mild and sunny' }); + expect(recorder.compiledSchemas).toEqual([FORECAST_OUTPUT_SCHEMA, FORECAST_OUTPUT_SCHEMA]); expect(recorder.validatedValues).toEqual([{ celsius: 21, summary: 'mild and sunny' }]); await expect(client.callTool({ name: 'forecast-corrupted', arguments: {} })).rejects.toBeInstanceOf(ProtocolError); diff --git a/test/e2e/tsconfig.json b/test/e2e/tsconfig.json index 453684bc8b..c9440778bf 100644 --- a/test/e2e/tsconfig.json +++ b/test/e2e/tsconfig.json @@ -20,6 +20,7 @@ "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], "@modelcontextprotocol/server/validators/ajv": ["./node_modules/@modelcontextprotocol/server/src/validators/ajv.ts"], "@modelcontextprotocol/server/validators/cf-worker": ["./node_modules/@modelcontextprotocol/server/src/validators/cfWorker.ts"], + "@modelcontextprotocol/server-legacy": ["./node_modules/@modelcontextprotocol/server-legacy/src/index.ts"], "@modelcontextprotocol/server-legacy/sse": ["./node_modules/@modelcontextprotocol/server-legacy/src/sse/index.ts"], "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], "@modelcontextprotocol/fastify": ["./node_modules/@modelcontextprotocol/fastify/src/index.ts"], diff --git a/test/e2e/types.ts b/test/e2e/types.ts index c7ff6bdd80..cef6bd9ea8 100644 --- a/test/e2e/types.ts +++ b/test/e2e/types.ts @@ -2,9 +2,29 @@ * 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 leg. `entryStateless` serves + * a plain 2025-era client through the entry's stateless legacy fallback (the + * default posture); `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 +34,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 + * stateless legacy fallback 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 +65,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 +123,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..0624dacc7c --- /dev/null +++ b/test/integration/test/__fixtures__/dualEraStdioServer.ts @@ -0,0 +1,33 @@ +/** + * A dual-era stdio server fixture: the connection-pinned `serveStdio` entry + * over an ordinary `McpServer` factory. Spawned as a real child process by + * `test/server/dualEraStdio.test.ts`; each spawned process serves exactly one + * connection, pinned to the era its client opens with. + */ +import { McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +const handle = serveStdio(() => { + const server = new McpServer( + { name: 'dual-era-stdio-fixture', version: '1.0.0' }, + { capabilities: { tools: {} }, instructions: 'dual-era stdio fixture' } + ); + server.registerTool( + 'echo', + { description: 'Echoes the input text', inputSchema: z.object({ text: z.string() }) }, + async ({ text }) => ({ + content: [{ type: 'text', text }] + }) + ); + return server; +}); + +const exit = async () => { + await handle.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..8de980f16a 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -6,6 +6,7 @@ import { ProtocolErrorCode, SdkError, SdkErrorCode, + setNegotiatedProtocolVersion, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; import { McpServer, Server } from '@modelcontextprotocol/server'; @@ -171,6 +172,64 @@ 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) => { + const server = new Server({ name: 'modern server', version: '1.0' }, { capabilities: {}, supportedProtocolVersions }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + // Stand-in for the modern-era server entry (instance binding): mark the server instance + // as serving the modern era so it can answer the client's server/discover probe. + setNegotiatedProtocolVersion(server, MODERN_REVISION); + 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 +1828,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 +1906,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 +2134,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 +2220,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..c79e148682 --- /dev/null +++ b/test/integration/test/client/discoverRoundtrip.test.ts @@ -0,0 +1,171 @@ +/** + * 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). + * + * Era is instance state on the server: an inbound `server/discover` is served + * only by a modern-era instance (the method is physically absent from the + * legacy registry). Production binding of modern-era instances belongs to the + * server-side entry that classifies inbound traffic; until it lands these + * tests bind the instance through the package-internal hook it will use. + */ +import type { Server as HttpServer } from 'node:http'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { SdkError, SdkErrorCode, setNegotiatedProtocolVersion, 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: { modernEraInstance: boolean }) { + const httpServer: HttpServer = createServer(); + const mcpServer = new McpServer( + { name: 'dual-era-server', version: '2.0.0' }, + { + capabilities: { tools: { listChanged: true } }, + supportedProtocolVersions: DUAL_ERA_VERSIONS, + instructions: 'dual era' + } + ); + mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + const serverTransport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await mcpServer.connect(serverTransport); + if (options.modernEraInstance) { + // Stand-in for the server-side entry (instance binding): mark the + // instance as serving the modern era so it can answer the probe. + setNegotiatedProtocolVersion(mcpServer.server, MODERN); + } + 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({ modernEraInstance: true }); + 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 carries listChanged-class capabilities now that + // the serving entries serve subscriptions/listen, visible end to end. + expect(client.getServerCapabilities()).toEqual({ tools: { listChanged: true } }); + + 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({ modernEraInstance: true }); + 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 the same server NOT bound to the modern era falls back to the legacy handshake', async () => { + // A server instance serves the legacy era until it is bound to the + // modern one (binding is owned by the server-side entry); the probe is + // answered -32601 and the client falls back cleanly on the same + // connection. + const baseUrl = await startServer({ modernEraInstance: false }); + 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 server with a dual-era list never meets a 2026 version string (counter-offer ordering, e2e)', async () => { + const baseUrl = await startServer({ modernEraInstance: false }); + 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..0cc2f2546c --- /dev/null +++ b/test/integration/test/server/createMcpHandler.test.ts @@ -0,0 +1,218 @@ +/** + * 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 fallback — both legacy postures (the stateless default and + * the strict 'reject') 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, + SUBSCRIPTION_ID_META_KEY +} from '@modelcontextprotocol/core'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import type { CreateMcpHandlerOptions, McpHttpHandler, McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, 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 (legacy postures 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(toNodeHandler(handler)); + const baseUrl = await listenOnRandomPort(httpServer); + cleanups.push(async () => { + await handler.close(); + httpServer.close(); + }); + return { baseUrl, handler }; + } + + it('serves the modern era to an auto-negotiating client (default endpoint)', 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'); + + const result = await client.callTool({ name: 'greet', arguments: { name: 'modern' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hello modern (modern)' }]); + }); + + it("rejects a plain 2025 client on a strict (legacy: 'reject') endpoint with the unsupported-protocol-version error", async () => { + const { baseUrl } = await startEndpoint({ legacy: 'reject' }); + + 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 default stateless legacy fallback while the modern path keeps working', async () => { + const { baseUrl } = await startEndpoint(); + + 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.callTool({ name: 'greet', arguments: { name: 'new friend' } }); + expect(modernResult.content).toEqual([{ type: 'text', text: 'hello new friend (modern)' }]); + }); + + 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: { + [PROTOCOL_VERSION_META_KEY]: '2030-01-01', + [CLIENT_INFO_META_KEY]: { name: 'integration-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + } + } + }) + }); + 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_022); + 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); + }); +}); + +describe('createMcpHandler over HTTP — subscriptions/listen honored filter', () => { + const cleanups: Array<() => Promise | void> = []; + afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!(); + }); + + it("drops a requested type the server's declared capabilities do not advertise", async () => { + // Factory declares tools.listChanged but NOT prompts.listChanged: a listen + // request that asks for both must be acknowledged with prompts dropped — + // the honored filter is narrowed against the per-serve instance's + // capabilities, not echoed verbatim. + const handler = createMcpHandler( + () => new McpServer({ name: 'caps-gated', version: '1' }, { capabilities: { tools: { listChanged: true } } }), + { keepAliveMs: 0 } + ); + const httpServer: HttpServer = createServer(toNodeHandler(handler)); + const baseUrl = await listenOnRandomPort(httpServer); + cleanups.push(async () => { + await handler.close(); + httpServer.close(); + }); + + const response = await fetch(new URL('/mcp', baseUrl), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'sub-1', + method: 'subscriptions/listen', + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'integration-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }, + notifications: { toolsListChanged: true, promptsListChanged: true } + } + }) + }); + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe('text/event-stream'); + + // Read the first SSE frame (the ack) and stop. + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let ack: { method: string; params: { notifications: Record; _meta: Record } } | undefined; + while (ack === undefined) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const idx = buffer.indexOf('\n\n'); + if (idx !== -1) { + const frame = buffer.slice(0, idx); + const dataLine = frame.split('\n').find(l => l.startsWith('data: ')); + if (dataLine) ack = JSON.parse(dataLine.slice(6)); + } + } + await reader.cancel(); + + expect(ack?.method).toBe('notifications/subscriptions/acknowledged'); + expect(ack?.params.notifications).toEqual({ toolsListChanged: true }); + expect(ack?.params.notifications).not.toHaveProperty('promptsListChanged'); + expect(ack?.params._meta[SUBSCRIPTION_ID_META_KEY]).toBe('sub-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..091d7aba26 --- /dev/null +++ b/test/integration/test/server/dualEraStdio.test.ts @@ -0,0 +1,227 @@ +/** + * Real-pipe dual-era stdio coverage for the connection-pinned `serveStdio` + * entry: the fixture server (`__fixtures__/dualEraStdioServer.ts`, one + * `McpServer` factory behind `serveStdio`) is spawned as a real child process + * — once per connection — and driven over its stdio pipe by + * + * - a plain 2025 client (the `initialize` vertical, served exactly as today, + * with the era gate staying vocabulary-clean on that connection), + * - the negotiating client in auto mode (the 2026-07-28 vertical: + * `server/discover` on the pipe, then list → call with the per-request + * envelope; a late claim-less `initialize` on the pinned connection answers + * the version error naming the supported revisions), and + * - a raw probe-then-fallback exchange (`server/discover` answered, then the + * client falls back to `initialize` on the same pipe and is served a normal + * 2025 session by a fresh legacy instance). + * + * 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']; + +const modernEnvelope = (clientName: string) => ({ + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: clientName, version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}); + +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('serveStdio over a real child-process pipe (one connection per spawned process)', () => { + vi.setConfig({ testTimeout: 30_000 }); + + it('legacy-opening connection: a plain 2025 client is served via initialize, and the connection stays vocabulary-clean', 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 this 2025-pinned connection: a claim-less + // 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-opening connection: the auto-negotiating client reaches 2026-07-28 via server/discover, the connection pins modern, and a late initialize is rejected with the supported list', 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. The raw list carries a hand-built + // envelope so the resultType marker can be read on the wire; the + // typed call goes through the client, which attaches the envelope + // itself on the modern-negotiated connection. + const modernList = await rawRequest(transport, inbound, { + jsonrpc: '2.0', + id: 'raw-modern-list', + method: 'tools/list', + params: { _meta: modernEnvelope('modern-pipe-client') } + }); + 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.callTool({ name: 'echo', arguments: { text: 'modern leg' } }); + expect(result.content).toEqual([{ type: 'text', text: 'modern leg' }]); + + // The connection is pinned to the 2026 era: a late claim-less + // initialize is answered with the version error naming the + // supported revisions, never served as a legacy handshake. + const lateInitialize = await rawRequest(transport, inbound, { + jsonrpc: '2.0', + id: 'raw-late-initialize', + method: 'initialize', + params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'late', version: '0' } } + }); + const lateError = (lateInitialize as { error: { code: number; data?: { supported?: string[] } } }).error; + expect(lateError.code).toBe(-32_022); + expect(lateError.data?.supported).toContain(MODERN); + } finally { + await client.close(); + } + }); + + it('probe-then-fallback connection: server/discover is answered, then an initialize on the same pipe is served a normal 2025 session', async () => { + const transport = spawnFixtureTransport(); + const inbound: JSONRPCMessage[] = []; + transport.onmessage = message => void inbound.push(message); + transport.onerror = () => {}; + + try { + await transport.start(); + + // The probe is answered by the optimistically built modern instance. + const discover = await rawRequest(transport, inbound, { + jsonrpc: '2.0', + id: 'probe-1', + method: 'server/discover', + params: { _meta: modernEnvelope('fallback-pipe-client') } + }); + const discoverResult = (discover as { result?: { supportedVersions?: string[]; resultType?: string } }).result; + expect(discoverResult?.supportedVersions).toEqual([MODERN]); + expect(discoverResult?.resultType).toBe('complete'); + + // The client shares no modern revision and falls back to the 2025 + // handshake on the same connection: a fresh legacy instance serves it. + const init = await rawRequest(transport, inbound, { + jsonrpc: '2.0', + id: 'fallback-init', + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'fallback-pipe-client', version: '1.0.0' } + } + }); + const initResult = (init as { result?: { protocolVersion?: string } }).result; + expect(initResult?.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + expect(JSON.stringify(init)).not.toContain('resultType'); + + await transport.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + // The legacy session works end to end after the fallback. + const list = await rawRequest(transport, inbound, { jsonrpc: '2.0', id: 'fallback-list', method: 'tools/list', params: {} }); + const listResult = (list as { result?: { tools?: Array<{ name: string }>; resultType?: string } }).result; + expect(listResult?.tools?.map(tool => tool.name)).toEqual(['echo']); + expect(listResult?.resultType).toBeUndefined(); + } finally { + await transport.close(); + } + }); +}); diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 8c844b11cb..3d0b3f1b31 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -2728,8 +2728,12 @@ describe('Zod v4', () => { } }) ).rejects.toMatchObject({ - code: ProtocolErrorCode.ResourceNotFound, - message: expect.stringContaining('not found') + // SEP-2164: resources/read miss is −32602 Invalid Params on the wire + // (every protocol revision); the encode seam maps a handler-thrown + // −32002 to −32602, and `data.uri` echoes the requested URI. + code: ProtocolErrorCode.InvalidParams, + message: expect.stringMatching(/not found/i), + data: { uri: 'test://nonexistent' } }); }); 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 diff --git a/typedoc.config.mjs b/typedoc.config.mjs index f2a4e50f56..675e3656b3 100644 --- a/typedoc.config.mjs +++ b/typedoc.config.mjs @@ -32,7 +32,7 @@ export default { exclude: ['**/*.examples.ts'] }, highlightLanguages: [...OptionDefaults.highlightLanguages, 'powershell'], - projectDocuments: ['docs/documents.md', 'packages/middleware/README.md', 'examples/server/README.md', 'examples/client/README.md'], + projectDocuments: ['docs/documents.md', 'packages/middleware/README.md', 'examples/README.md'], hostedBaseUrl: 'https://ts.sdk.modelcontextprotocol.io/v2/', navigationLinks: { 'V1 Docs': '/'