diff --git a/.changeset/era-gate-explicit-schema-handlers.md b/.changeset/era-gate-explicit-schema-handlers.md new file mode 100644 index 0000000000..db051a95b9 --- /dev/null +++ b/.changeset/era-gate-explicit-schema-handlers.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +A server can now serve, and a client can now call, `tasks/get` and `tasks/cancel` of the Tasks extension (SEP-2663) on a 2026-07-28 connection, when the handler is registered and the request is sent with an explicit schema. Every other method that a protocol revision removed is still refused. If one server factory serves both eras and such a handler is meant for 2025-era clients only, register it only when `ctx.era === 'legacy'`. diff --git a/.changeset/origin-scheme-entries.md b/.changeset/origin-scheme-entries.md new file mode 100644 index 0000000000..2f3513dc0a --- /dev/null +++ b/.changeset/origin-scheme-entries.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': minor +--- + +`allowedOrigins` and `validateOriginHeader` accept lowercase entries of the form `://*`, such as `moz-extension://*` or `chrome-extension://*`, which admit every origin of that scheme. This lets a server admit MCP clients that run as a browser extension when the extension ID cannot be listed, as on Firefox, where it differs on every install. `http://*` and `https://*` are not honoured, and the defaults are unchanged. diff --git a/.changeset/package-license-field.md b/.changeset/package-license-field.md new file mode 100644 index 0000000000..68cd32c989 --- /dev/null +++ b/.changeset/package-license-field.md @@ -0,0 +1,13 @@ +--- +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/codemod': patch +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/server-legacy': patch +'@modelcontextprotocol/node': patch +'@modelcontextprotocol/express': patch +'@modelcontextprotocol/fastify': patch +'@modelcontextprotocol/hono': patch +--- + +The `license` field of the package manifests is now `Apache-2.0`; the `LICENSE` file shipped in each package carries the full terms, including the MIT text for earlier contributions. No code change. diff --git a/.changeset/probe-unusable-2xx-reply-message.md b/.changeset/probe-unusable-2xx-reply-message.md new file mode 100644 index 0000000000..13309768ff --- /dev/null +++ b/.changeset/probe-unusable-2xx-reply-message.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/client': patch +--- + +With `versionNegotiation` in `'auto'` or pin mode, a `server/discover` probe answered with a 2xx that carries no usable reply (a body that +is not JSON under `application/json`, a bare `204`, a missing or unaccepted content type) still rejects `connect()` with +`EraNegotiationFailed`; an empty SSE stream or a `202` surfaces as the probe timeout instead. The message now says +`the server answered with an unusable reply (...)` instead of reading like a network failure. To connect to a 2025 server behind a front that +answers the probe this way, pass `connect(transport, { prior: { kind: 'legacy' } })` or use `mode: 'legacy'`. diff --git a/.changeset/register-prompt-context-only-overload.md b/.changeset/register-prompt-context-only-overload.md new file mode 100644 index 0000000000..26203274ea --- /dev/null +++ b/.changeset/register-prompt-context-only-overload.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +`McpServer.registerPrompt()` now types the callback correctly when no `argsSchema` is given: its one parameter is the server context. Before, reading `ctx.mcpReq` there was a type error although it worked at runtime. Prompts registered with an `argsSchema` are unchanged. diff --git a/.changeset/sse-connect-retry-once.md b/.changeset/sse-connect-retry-once.md new file mode 100644 index 0000000000..cb90c3aa39 --- /dev/null +++ b/.changeset/sse-connect-retry-once.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +`SSEClientTransport` now retries the SSE connection once after `onUnauthorized()` resolves, as documented. If the retry is also answered with 401, `start()` rejects with `SdkHttpError` (`ClientHttpAuthentication`) instead of calling `onUnauthorized()` again. A 401 on a later reconnect of a stream that had opened still gets one refresh. diff --git a/.github/workflows/update-spec-types.yml b/.github/workflows/update-spec-types.yml deleted file mode 100644 index c62aca8907..0000000000 --- a/.github/workflows/update-spec-types.yml +++ /dev/null @@ -1,95 +0,0 @@ -# Nightly refresh of the draft-tracking spec anchor (2026-07-28). -# -# Anchor lifecycle (see packages/core-internal/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: - schedule: - # Run nightly at 4 AM UTC - - cron: '0 4 * * *' - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -jobs: - update-spec-types: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Install pnpm - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 - id: pnpm-install - with: - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install dependencies - run: pnpm install - - - name: Fetch latest spec types - run: pnpm run fetch:spec-types 2026-07-28 - - - name: Check for changes - id: check_changes - run: | - if git diff --quiet packages/core-internal/src/types/spec.types.2026-07-28.ts; then - echo "has_changes=false" >> $GITHUB_OUTPUT - else - echo "has_changes=true" >> $GITHUB_OUTPUT - LATEST_SHA=$(grep "Last updated from commit:" packages/core-internal/src/types/spec.types.2026-07-28.ts | cut -d: -f2 | tr -d ' ') - echo "sha=$LATEST_SHA" >> $GITHUB_OUTPUT - fi - - - name: Create Pull Request - if: steps.check_changes.outputs.has_changes == 'true' - env: - GH_TOKEN: ${{ github.token }} - # Skip lefthook pre-push (typecheck/lint/build); spec drift that breaks - # typecheck should still open a PR so it can be fixed there. - LEFTHOOK: 0 - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - git checkout -B update-spec-types - git add packages/core-internal/src/types/spec.types.2026-07-28.ts - git commit -m "chore: update spec.types.2026-07-28.ts from upstream" - git push -f --no-verify origin update-spec-types - - # Create PR if it doesn't exist, or update if it does - PR_BODY="This PR updates \`packages/core-internal/src/types/spec.types.2026-07-28.ts\` from the Model Context Protocol specification. - - Source file: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/${{ steps.check_changes.outputs.sha }}/schema/draft/schema.ts - - This is an automated update triggered by the nightly cron job." - - # `gh pr view ` matches closed PRs too, so check for an *open* PR explicitly. - EXISTING_PR=$(gh pr list --head update-spec-types --state open --json number --jq '.[0].number // empty') - if [ -n "$EXISTING_PR" ]; then - echo "PR #$EXISTING_PR already exists, updating description..." - gh pr edit "$EXISTING_PR" --body "$PR_BODY" - else - gh pr create \ - --title "chore: update spec.types.2026-07-28.ts from upstream" \ - --body "$PR_BODY" \ - --base main \ - --head update-spec-types - fi diff --git a/README.md b/README.md index 6d5e2328ef..749070e8c5 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ > [Issues](https://github.com/modelcontextprotocol/typescript-sdk/issues/new?template=v2-feedback.yml) are the most useful feedback right now — we'll reopen PRs as v2 stabilizes. [![NPM Version - Server](https://img.shields.io/npm/v/%40modelcontextprotocol%2Fserver?label=%40modelcontextprotocol%2Fserver)](https://www.npmjs.com/package/@modelcontextprotocol/server) -[![NPM Version - Client](https://img.shields.io/npm/v/%40modelcontextprotocol%2Fclient?label=%40modelcontextprotocol%2Fclient)](https://www.npmjs.com/package/@modelcontextprotocol/client) ![MIT licensed](https://img.shields.io/npm/l/%40modelcontextprotocol%2Fserver) +[![NPM Version - Client](https://img.shields.io/npm/v/%40modelcontextprotocol%2Fclient?label=%40modelcontextprotocol%2Fclient)](https://www.npmjs.com/package/@modelcontextprotocol/client) ![Apache-2.0 licensed](https://img.shields.io/npm/l/%40modelcontextprotocol%2Fserver)
Table of Contents diff --git a/REVIEW.md b/REVIEW.md index 9047acb1c2..65b4c35935 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -32,7 +32,7 @@ review rounds and grows over time. ## Checklist **Protocol & spec** -- Types match [`schema.ts`](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.ts) exactly (optional vs required fields) +- Types match the schema for the protocol revision being changed (`schema//schema.ts` for released revisions; `schema/draft/schema.ts` only for unreleased work) - Correct `ProtocolError` codes (enum `ProtocolErrorCode`); HTTP status codes match spec (e.g., 404 vs 410) - Works for both stdio and Streamable HTTP transports — no transport-specific assumptions - Cross-SDK consistency: check what `python-sdk` does for the same feature @@ -61,7 +61,7 @@ When verifying spec compliance, consult the spec directly rather than relying on - MCP documentation server: `https://modelcontextprotocol.io/mcp` - Full spec text (single file, LLM-friendly): `https://modelcontextprotocol.io/llms-full.txt` — fetch to a temp file and grep for the relevant section -- Schema source of truth: [`schema.ts`](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.ts) +- Schema source of truth: the revision-matched `schema.ts` in `modelcontextprotocol/modelcontextprotocol` (`schema//schema.ts` for released revisions; `schema/draft/schema.ts` only for unreleased work) ## Recurring Catches diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index 01ae6f0542..c7b087a3ef 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -79,7 +79,7 @@ falling back — see [Protocol versions](../protocol-versions.md). This holds ev a front that answers the probe `403` but would pass `initialize`: relying on a legacy fallback there is a deliberate non-goal — fix the auth wall (or pass credentials), because auth status never selects an era. A `5xx` on the probe is a server failure, -also never era evidence: `connect()` rejects with `SdkHttpError(EraNegotiationFailed)`. Probe timeouts are **transport-aware**: on **stdio** a server that does not +also never era evidence: `connect()` rejects with `SdkHttpError(EraNegotiationFailed)`. A `2xx` probe answer that carries no usable reply (a body that is not JSON under `application/json`, a bare `204`, a missing or unaccepted content type) is not era evidence either: `connect()` rejects with `SdkError(EraNegotiationFailed)`; an empty SSE stream or a `202` surfaces as the probe timeout instead. 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` (some legacy servers never respond to unknown pre-`initialize` requests at all); on **HTTP** a probe timeout rejects with `SdkError(RequestTimeout)` — @@ -349,11 +349,20 @@ mismatch is rejected as an entry/routing error (`-32022 Unsupported protocol ver 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 +registry: an inbound `ping` or `tasks/result` 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 `ping` toward a 2026-era peer) throws `SdkError(MethodNotSupportedByProtocolVersion)` before anything reaches the transport. +The one exception is the Tasks extension (SEP-2663), which keeps two names the +2025-11-25 revision had in core: `tasks/get` and `tasks/cancel`. A handler registered +for them with an explicit schema (`setRequestHandler('tasks/get', { params, result }, +handler)`) is served, and a request sent with an explicit result schema +(`request({ method: 'tasks/get', params }, resultSchema)`) is sent, on every era. With +no handler, or with a method-keyed handler, they still get `-32601`. If one factory +serves both eras and such a handler is meant for 2025-era clients only, register it only +when `ctx.era === 'legacy'`; otherwise it answers 2026-07-28 requests too. + If you were on a v2 alpha and consumed wire schemas directly: | v2-alpha pattern | Mechanical fix | @@ -700,9 +709,13 @@ methods at compile time. `ResultTypeMap['tools/call']` is plain `CallToolResult` maps still carry the `tasks/*` entries and the `CreateTaskResult` unions; narrow with the `isCallToolResult` guard if you are pinned to one of those alphas. `2.0.0-alpha.4` and later include the exclusion.) Where -task interop is genuinely required, use the explicit-schema custom-method form -(`request({ method: 'tasks/get', params }, GetTaskResultSchema)`). Inbound `tasks/*` -requests → `-32601`. +task interop is genuinely required, use the explicit-schema custom-method form on both +sides: `request({ method: 'tasks/get', params }, resultSchema)` to send and +`setRequestHandler('tasks/get', { params, result }, handler)` to serve, with the schemas +of the revision you are talking to (`GetTaskResultSchema` is the 2025-11-25 shape). On a +2026-era connection only `tasks/get` and `tasks/cancel`, the two names the Tasks +extension keeps, are served and sent this way; `tasks/result` and `tasks/list` stay +refused there. With no handler registered, inbound `tasks/*` requests get `-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). diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index 06e3d174f2..02f65efd60 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -99,6 +99,8 @@ const cli = new Client( ); ``` +A `2xx` answer that carries no usable reply (a body that is not JSON under `application/json`, a bare `204`, a missing or unaccepted content type) is not era evidence: `connect()` rejects with `SdkError(EraNegotiationFailed)` and the message says `the server answered with an unusable reply`. An empty SSE stream or a `202` surfaces as the probe timeout instead. To connect to a 2025 server behind a front that answers the probe this way, pass `connect(transport, { prior: { kind: 'legacy' } })`. + A probe timeout is transport-aware. On stdio a silent server is a legacy server, so `connect()` falls back to `initialize`; on HTTP silence is an outage, so `connect()` rejects with `SdkError(RequestTimeout)` instead of misreporting a dead server as legacy. One browser exception: an opaque CORS `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly have allow-lists that predate the 2026 headers. Auth statuses are not era evidence either. An HTTP `401` or `403` rejecting the probe surfaces as a typed authorization failure, never the legacy fallback — and never as `EraNegotiationFailed`, so era-recovery flows keyed on that code (the [gateway guide](./advanced/gateway.md) recipe) cannot consume an auth wall. A plain `401` or `403` — with or without an `authProvider` — rejects `connect()` with an `SdkHttpError` carrying the status: code `ClientHttpAuthentication` for `401`, `ClientHttpForbidden` for `403`. One 403 shape is different: a `WWW-Authenticate` challenge with `error="insufficient_scope"` enters the Streamable HTTP transport's step-up flow regardless of provider, and with none it rejects with the flow's typed `InsufficientScopeError`. With a provider, a `401` (and a `403` `insufficient_scope` challenge) runs the auth flow first, and whatever escapes it reaches you as thrown, identity intact — the transport stamps errors at its auth seams (the `token()` read, `onUnauthorized` including custom callbacks, step-up), so `UnauthorizedError` for `finishAuth()`, the flow's typed failures (`OAuthError`, `InsufficientScopeError`, the 401-after-re-authentication diagnostic), and even an untyped crash inside a callback all propagate unchanged. A `5xx` rejecting the probe is a server failure, not era evidence: `connect()` rejects with `SdkHttpError(EraNegotiationFailed)` naming the status. These status-keyed rows read the transport's typed HTTP rejections — the SDK's Streamable HTTP transport surfaces them with the status attached; the legacy SSE client transport reports a non-2xx POST as a generic error, so over SSE a probe rejection surfaces as the generic `Version negotiation probe failed` connect error instead. Auth settles first, era second: a `401` never decides the era — the auth wall answers before the MCP layer ever sees `server/discover` — and the post-auth re-probe supplies the real era evidence. diff --git a/docs/servers/input-required.md b/docs/servers/input-required.md index 1fc42383f1..d17731d43c 100644 --- a/docs/servers/input-required.md +++ b/docs/servers/input-required.md @@ -174,6 +174,9 @@ Sampling and roots are deprecated as of protocol revision 2026-07-28 (SEP-2577) To run rounds in sequence, return an opaque `requestState` string alongside the requests. The client echoes it back byte-for-byte on the retry, and `ctx.mcpReq.requestState()` reads its decoded payload on re-entry. Mint it with the codec from the next section. ```ts source="../../examples/guides/servers/input-required.examples.ts#requestState_mint" +const cacheConfirmationSchema = z.object({ confirm: z.boolean() }); +const cacheScopeSchema = z.object({ scope: z.string() }); + server.registerTool( 'wipe-cache', { description: 'Confirm, then pick a scope, then wipe', inputSchema: z.object({}) }, @@ -181,13 +184,13 @@ server.registerTool( const state = ctx.mcpReq.requestState<{ step: string }>(); if (state?.step !== 'confirmed') { - const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', cacheConfirmationSchema); if (confirmed?.confirm !== true) { return inputRequired({ inputRequests: { confirm: inputRequired.elicit({ message: 'Really wipe the cache?', - requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + requestedSchema: cacheConfirmationSchema }) } }); @@ -197,15 +200,23 @@ server.registerTool( inputRequests: { scope: inputRequired.elicit({ message: 'Which scope?', - requestedSchema: { type: 'object', properties: { scope: { type: 'string' } }, required: ['scope'] } + requestedSchema: cacheScopeSchema }) }, requestState: await stateCodec.mint({ step: 'confirmed' }) }); } - const scope = acceptedContent<{ scope: string }>(ctx.mcpReq.inputResponses, 'scope'); - return { content: [{ type: 'text', text: `Wiped ${scope?.scope ?? 'all'}` }] }; + const scope = acceptedContent(ctx.mcpReq.inputResponses, 'scope', cacheScopeSchema); + if (scope === undefined) { + return inputRequired({ + inputRequests: { + scope: inputRequired.elicit({ message: 'Which scope?', requestedSchema: cacheScopeSchema }) + }, + requestState: await stateCodec.mint({ step: 'confirmed' }) + }); + } + return { content: [{ type: 'text', text: `Wiped ${scope.scope}` }] }; } ); ``` diff --git a/docs/serving/express.md b/docs/serving/express.md index 1d04189a97..f634ac918a 100644 --- a/docs/serving/express.md +++ b/docs/serving/express.md @@ -42,7 +42,7 @@ Binding to all interfaces drops that default — name the hosts you serve instea const publicApp = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); ``` -`allowedHosts` and `allowedOrigins` take hostnames, port-agnostic. A request without an `Origin` header always passes, so MCP clients outside a browser are unaffected. +`allowedHosts` and `allowedOrigins` take hostnames, port-agnostic. A request without an `Origin` header always passes, so MCP clients outside a browser are unaffected. Passing `allowedOrigins` replaces the default list, so start from `localhostAllowedOrigins()` (from `@modelcontextprotocol/server`) to keep it. A client that runs as a browser extension sends its extension ID as `Origin`: add that ID to admit one extension, or a lowercase scheme entry such as `'moz-extension://*'` to admit every extension of that browser, which Firefox needs because its IDs differ on every install. `http://*` and `https://*` are not honoured. ## Forward auth and the parsed body diff --git a/docs/serving/fastify.md b/docs/serving/fastify.md index 1643ec4d48..aebbb7d52d 100644 --- a/docs/serving/fastify.md +++ b/docs/serving/fastify.md @@ -42,7 +42,7 @@ Binding to all interfaces drops that default — name the hosts you serve instea const publicApp = createMcpFastifyApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); ``` -`allowedHosts` and `allowedOrigins` take hostnames, port-agnostic. A request without an `Origin` header always passes, so MCP clients outside a browser are unaffected. +`allowedHosts` and `allowedOrigins` take hostnames, port-agnostic. A request without an `Origin` header always passes, so MCP clients outside a browser are unaffected. Passing `allowedOrigins` replaces the default list, so start from `localhostAllowedOrigins()` (from `@modelcontextprotocol/server`) to keep it. A client that runs as a browser extension sends its extension ID as `Origin`: add that ID to admit one extension, or a lowercase scheme entry such as `'moz-extension://*'` to admit every extension of that browser, which Firefox needs because its IDs differ on every install. `http://*` and `https://*` are not honoured. ## Forward auth and the parsed body diff --git a/docs/serving/hono.md b/docs/serving/hono.md index a2adfde9c8..257d6974c2 100644 --- a/docs/serving/hono.md +++ b/docs/serving/hono.md @@ -47,7 +47,7 @@ Binding to all interfaces drops that default — name the hosts you serve instea const publicApp = createMcpHonoApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); ``` -`allowedHosts` and `allowedOrigins` take hostnames, port-agnostic. A request without an `Origin` header always passes, so MCP clients outside a browser are unaffected. +`allowedHosts` and `allowedOrigins` take hostnames, port-agnostic. A request without an `Origin` header always passes, so MCP clients outside a browser are unaffected. Passing `allowedOrigins` replaces the default list, so start from `localhostAllowedOrigins()` (from `@modelcontextprotocol/server`) to keep it. A client that runs as a browser extension sends its extension ID as `Origin`: add that ID to admit one extension, or a lowercase scheme entry such as `'moz-extension://*'` to admit every extension of that browser, which Firefox needs because its IDs differ on every install. `http://*` and `https://*` are not honoured. ## Forward auth and the parsed body diff --git a/docs/serving/web-standard.md b/docs/serving/web-standard.md index 87d7439eb3..cfc7b128ea 100644 --- a/docs/serving/web-standard.md +++ b/docs/serving/web-standard.md @@ -44,7 +44,7 @@ const guarded = { }; ``` -A request whose `Host` is not on the list gets `403` before `handler.fetch` runs; both helpers take hostnames, port-agnostic, and a request without an `Origin` header always passes. For a localhost-only process, `localhostAllowedHostnames()` and `localhostAllowedOrigins()` (same package) replace the explicit lists. +A request whose `Host` is not on the list gets `403` before `handler.fetch` runs; both helpers take hostnames, port-agnostic, and a request without an `Origin` header always passes. For a localhost-only process, `localhostAllowedHostnames()` and `localhostAllowedOrigins()` (same package) replace the explicit lists. A client that runs as a browser extension sends its extension ID as `Origin`: add that ID to the `originValidationResponse` list to admit one extension, or a lowercase scheme entry such as `'moz-extension://*'` to admit every extension of that browser, which Firefox needs because its IDs differ on every install. `http://*` and `https://*` are not honoured. ## Forward auth and the parsed body diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d9e6e7824d..f17adf26e6 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -74,6 +74,7 @@ With the global in place the [client OAuth](./clients/oauth.md) flows run unchan - `the server gave no modern evidence and this client supports no pre-2026-07-28 protocol version to fall back to` — or its `the connection closed during the server/discover probe and this client supports no ...` variant — `mode: 'auto'` with a modern-only `supportedProtocolVersions` list removes the legacy fallback: restore a pre-2026 entry. - `the connection closed during the server/discover probe (this transport probed in place — the disposable sibling probe requires the SDK's base StdioClientTransport)` — a subclass of `StdioClientTransport`, or a custom stdio-shaped transport, probed in place and met a server that exits on any pre-`initialize` request: use the base `StdioClientTransport` (which probes on a disposable sibling), or `mode: 'legacy'`. - `the transport was closed during the server/discover probe` — the caller closed the transport while the probe was in flight; the connect aborted deliberately and the session child was never spawned. +- `Version negotiation probe failed: the server answered with an unusable reply (...)` — the server, or something in front of it, answered the probe with a `2xx` that carries no usable reply (a body that is not JSON under `application/json`, a bare `204`, a missing or unaccepted content type; an empty SSE stream or a `202` surfaces as the probe timeout instead). That is not era evidence, so no legacy fallback is attempted: check the URL and what sits in front of the server. To connect to a 2025 server behind such a front, pass `connect(transport, { prior: { kind: 'legacy' } })` or use `mode: 'legacy'`. - `Version negotiation probe failed: ...` — the probe hit a transport failure (network outage, HTTP connection drop): fix connectivity and retry. - `the server answered the probe with HTTP 5xx` — the server or a proxy in front of it failed (mid-deploy, crashed backend); not era evidence, so no legacy fallback is attempted: retry once the deployment is healthy. diff --git a/examples/guides/servers/input-required.examples.ts b/examples/guides/servers/input-required.examples.ts index 8b3755ca17..e7702ba34f 100644 --- a/examples/guides/servers/input-required.examples.ts +++ b/examples/guides/servers/input-required.examples.ts @@ -125,6 +125,9 @@ server.registerTool( // "Carry state across rounds with `requestState`" — two sequential rounds. //#region requestState_mint +const cacheConfirmationSchema = z.object({ confirm: z.boolean() }); +const cacheScopeSchema = z.object({ scope: z.string() }); + server.registerTool( 'wipe-cache', { description: 'Confirm, then pick a scope, then wipe', inputSchema: z.object({}) }, @@ -132,13 +135,13 @@ server.registerTool( const state = ctx.mcpReq.requestState<{ step: string }>(); if (state?.step !== 'confirmed') { - const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', cacheConfirmationSchema); if (confirmed?.confirm !== true) { return inputRequired({ inputRequests: { confirm: inputRequired.elicit({ message: 'Really wipe the cache?', - requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + requestedSchema: cacheConfirmationSchema }) } }); @@ -148,15 +151,23 @@ server.registerTool( inputRequests: { scope: inputRequired.elicit({ message: 'Which scope?', - requestedSchema: { type: 'object', properties: { scope: { type: 'string' } }, required: ['scope'] } + requestedSchema: cacheScopeSchema }) }, requestState: await stateCodec.mint({ step: 'confirmed' }) }); } - const scope = acceptedContent<{ scope: string }>(ctx.mcpReq.inputResponses, 'scope'); - return { content: [{ type: 'text', text: `Wiped ${scope?.scope ?? 'all'}` }] }; + const scope = acceptedContent(ctx.mcpReq.inputResponses, 'scope', cacheScopeSchema); + if (scope === undefined) { + return inputRequired({ + inputRequests: { + scope: inputRequired.elicit({ message: 'Which scope?', requestedSchema: cacheScopeSchema }) + }, + requestState: await stateCodec.mint({ step: 'confirmed' }) + }); + } + return { content: [{ type: 'text', text: `Wiped ${scope.scope}` }] }; } ); //#endregion requestState_mint diff --git a/examples/mrtr/server.ts b/examples/mrtr/server.ts index 1f89fe236d..2eaa9b7bd1 100644 --- a/examples/mrtr/server.ts +++ b/examples/mrtr/server.ts @@ -30,7 +30,7 @@ import { acceptedContent, createMcpHandler, createRequestStateCodec, inputRequir 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'] }; +const CONFIRM_SCHEMA = z.object({ confirm: z.boolean() }); type DeployState = { step: 'confirm' | 'signed-in'; env: string }; @@ -67,8 +67,8 @@ function buildServer(): McpServer { console.error(`[server] tools/call deploy(${env}) step=${step}`); if (step === 'confirm') { - const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); - if (!confirmed?.confirm) { + const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', CONFIRM_SCHEMA); + if (confirmed?.confirm !== true) { return inputRequired({ inputRequests: { confirm: inputRequired.elicit({ message: `Deploy to ${env}?`, requestedSchema: CONFIRM_SCHEMA }) diff --git a/package.json b/package.json index 71424cb8f3..3072a5bfea 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "version": "2.0.0-alpha.0", "description": "Model Context Protocol implementation for TypeScript", - "license": "SEE LICENSE IN LICENSE", + "license": "Apache-2.0", "author": "Model Context Protocol a Series of LF Projects, LLC.", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", diff --git a/packages/client/package.json b/packages/client/package.json index 23c9a6dd82..66621972f9 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -2,7 +2,7 @@ "name": "@modelcontextprotocol/client", "version": "2.2.0", "description": "Model Context Protocol implementation for TypeScript - Client package", - "license": "MIT", + "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts index f73d57b393..8753f8e694 100644 --- a/packages/client/src/client/probeClassifier.ts +++ b/packages/client/src/client/probeClassifier.ts @@ -51,6 +51,8 @@ export type ProbeOutcome = /** The HTTP layer rejected the probe POST (non-2xx); `body` is the raw response text and `statusText` the HTTP reason phrase, when available. */ | { kind: 'http-error'; status: number; body?: string; statusText?: string } | { kind: 'network-error'; error: unknown } + /** The probe was answered 2xx without a usable reply (body is not JSON, or an unaccepted media type); `error` is the transport's failure. */ + | { kind: 'unusable-reply'; error: unknown } /** The transport's auth flow challenged or failed during the probe send — an error stamped at a transport auth seam, or an `UnauthorizedError` (the foreign-transport contract). `error` propagates unchanged. */ | { kind: 'auth-required'; error: Error } /** The transport reported close while the probe awaited its reply. */ @@ -139,6 +141,18 @@ export function classifyProbeOutcome(outcome: ProbeOutcome, context: ProbeClassi case 'network-error': { return classifyNetworkError(outcome.error, context); } + case 'unusable-reply': { + // An unreadable 2xx is not era evidence: reject and say what was answered, never fall back. + return { + kind: 'error', + error: new SdkError( + SdkErrorCode.EraNegotiationFailed, + `Version negotiation probe failed: the server answered with an unusable reply (${describeError(outcome.error)})`, + { cause: outcome.error }, + { cause: outcome.error } + ) + }; + } case 'auth-required': { // Not era evidence: propagate the auth challenge unchanged so the // caller can run finishAuth() and reconnect — the reconnect probes diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index e0b320b107..e249636ed2 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -175,6 +175,8 @@ export class SSEClientTransport implements Transport { } private _last401Response?: Response; + // True between a 401-triggered reconnect and the next successful open. + private _connectAuthRetried = false; private async _commonHeaders(): Promise { // Start from the caller-supplied `requestInit.headers` and `set()` the @@ -239,9 +241,10 @@ export class SSEClientTransport implements Transport { this._eventSource.onerror = event => { if (event.code === 401 && this._authProvider) { - if (this._authProvider.onUnauthorized && this._last401Response) { + if (this._authProvider.onUnauthorized && this._last401Response && !this._connectAuthRetried) { const response = this._last401Response; this._last401Response = undefined; + this._connectAuthRetried = true; this._eventSource?.close(); this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit }).then( // onUnauthorized succeeded → retry fresh. Its onerror handles its own onerror?.() + reject. @@ -250,6 +253,7 @@ export class SSEClientTransport implements Transport { // stamp: covers the SDK's OAuth flow and custom // callbacks alike. (error: unknown) => { + this._connectAuthRetried = false; markAuthSeamEscape(error); this.onerror?.(error as Error); reject(error); @@ -257,7 +261,17 @@ export class SSEClientTransport implements Transport { ); return; } - const error = markAuthSeamEscape(new UnauthorizedError()); + const retried = this._connectAuthRetried; + this._connectAuthRetried = false; + const error = markAuthSeamEscape( + retried + ? new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', { + status: 401, + statusText: this._last401Response?.statusText ?? '' + }) + : new UnauthorizedError() + ); + this._last401Response = undefined; reject(error); this.onerror?.(error); return; @@ -270,6 +284,7 @@ export class SSEClientTransport implements Transport { this._eventSource.onopen = () => { // The connection is open, but we need to wait for the endpoint to be received. + this._connectAuthRetried = false; }; this._eventSource.addEventListener('endpoint', (event: Event) => { diff --git a/packages/client/src/client/versionNegotiation.ts b/packages/client/src/client/versionNegotiation.ts index 9b89d962ba..5dfced8c7f 100644 --- a/packages/client/src/client/versionNegotiation.ts +++ b/packages/client/src/client/versionNegotiation.ts @@ -375,6 +375,15 @@ export function buildProbeRequest( }; } +/** True for what the transport throws when the probe was answered 2xx without a usable reply. */ +function isUnusableReplyError(error: unknown): boolean { + if (error instanceof SdkError && error.code === SdkErrorCode.ClientHttpUnexpectedContent) { + return true; + } + // Matched by name: this is what response.json() throws for a body that is not JSON. + return error instanceof Error && error.name === 'SyntaxError'; +} + function normalizeReply(reply: RawProbeReply, timeoutMs: number): ProbeOutcome { switch (reply.kind) { case 'response': { @@ -409,6 +418,9 @@ function normalizeReply(reply: RawProbeReply, timeoutMs: number): ProbeOutcome { statusText: error.data.statusText }; } + if (isUnusableReplyError(error)) { + return { kind: 'unusable-reply', error }; + } return { kind: 'network-error', error }; } case 'closed': { diff --git a/packages/client/test/client/probeClassifier.test.ts b/packages/client/test/client/probeClassifier.test.ts index ccc1efd54a..52dd23a59d 100644 --- a/packages/client/test/client/probeClassifier.test.ts +++ b/packages/client/test/client/probeClassifier.test.ts @@ -346,3 +346,40 @@ describe('row: browser opaque CORS/preflight TypeError, PROBE PHASE ONLY → leg expect(verdict.kind).toBe('error'); }); }); + +describe('row: unusable-reply — the HTTP layer answered 2xx with an unusable body → typed connect error', () => { + const jsonSyntaxError = () => { + try { + JSON.parse(''); + throw new Error('unreachable'); + } catch (error) { + return error; + } + }; + + test.each([true, false])('an empty JSON body is never an era verdict (fallbackAvailable: %s)', fallbackAvailable => { + const cause = jsonSyntaxError(); + const verdict = classify({ kind: 'unusable-reply', error: cause }, { fallbackAvailable }); + expect(verdict.kind).toBe('error'); + if (verdict.kind === 'error') { + expect(verdict.error).toBeInstanceOf(SdkError); + expect((verdict.error as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect(verdict.error.message).toContain('the server answered with an unusable reply'); + expect((verdict.error as SdkError).data).toMatchObject({ cause }); + expect(verdict.error.cause).toBe(cause); + } + }); + + test('an unaccepted media type (bare 204, text/plain) is never an era verdict', () => { + const cause = new SdkError(SdkErrorCode.ClientHttpUnexpectedContent, 'Unexpected content type: text/plain', { + contentType: 'text/plain' + }); + const verdict = classify({ kind: 'unusable-reply', error: cause }); + expect(verdict.kind).toBe('error'); + if (verdict.kind === 'error') { + expect((verdict.error as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect(verdict.error.message).toContain('the server answered with an unusable reply (Unexpected content type: text/plain)'); + expect(verdict.error.cause).toBe(cause); + } + }); +}); diff --git a/packages/client/test/client/probeUnusable2xx.wire.test.ts b/packages/client/test/client/probeUnusable2xx.wire.test.ts new file mode 100644 index 0000000000..659b552260 --- /dev/null +++ b/packages/client/test/client/probeUnusable2xx.wire.test.ts @@ -0,0 +1,156 @@ +// #2619: the real StreamableHTTPClientTransport against a fetch stub that plays a gateway in front of a 2025 server. +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal'; +import { describe, expect, test } from 'vitest'; + +import { Client } from '../../src/client/client'; +import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp'; + +const JSON_CT = { 'content-type': 'application/json' }; +const UNUSABLE = 'the server answered with an unusable reply'; + +function gatewayInFrontOfLegacy(probeAnswer: () => Response | Promise) { + const seen: string[] = []; + const fetchStub = async (_input: Parameters[0], init?: Parameters[1]): Promise => { + if (init?.method !== 'POST') return new Response(null, { status: 405 }); + const message = JSON.parse(String(init.body)) as { id?: unknown; method?: string }; + seen.push(message.method ?? '(response)'); + if (message.method === 'server/discover') return probeAnswer(); + if (message.method === 'initialize') { + return Response.json( + { + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: '2025-11-25', + capabilities: {}, + serverInfo: { name: 'legacy-behind-gateway', version: '1.0.0' } + } + }, + { status: 200, headers: JSON_CT } + ); + } + return new Response(null, { status: 202 }); + }; + return { fetchStub: fetchStub as typeof fetch, seen }; +} + +async function connect( + options: ConstructorParameters[1], + probeAnswer: () => Response | Promise, + connectOptions?: Parameters[1] +) { + const { fetchStub, seen } = gatewayInFrontOfLegacy(probeAnswer); + const client = new Client({ name: 'repro-2619', version: '1.0.0' }, options); + const transport = new StreamableHTTPClientTransport(new URL('http://gateway.invalid/mcp'), { fetch: fetchStub }); + const outcome = await client.connect(transport, connectOptions).then( + () => 'connected' as const, + (error: unknown) => error + ); + const era = outcome === 'connected' ? client.getProtocolEra() : undefined; + const server = outcome === 'connected' ? client.getServerVersion()?.name : undefined; + await client.close().catch(() => {}); + return { outcome, era, server, seen }; +} + +const AUTO = { versionNegotiation: { mode: 'auto' as const } }; +const emptyJson = () => new Response('', { status: 200, headers: JSON_CT }); + +describe('#2619: the four rows of the issue table (real transport, injected fetch)', () => { + const rows: Array<[string, () => Response, string]> = [ + ['200, application/json, empty body', emptyJson, 'SyntaxError'], + ['200, application/json, whitespace body', () => new Response(' \n', { status: 200, headers: JSON_CT }), 'SyntaxError'], + ['204, no content-type', () => new Response(null, { status: 204 }), 'SdkError'], + ['200, text/plain', () => new Response('OK', { status: 200, headers: { 'content-type': 'text/plain' } }), 'SdkError'] + ]; + + test.each(rows)('%s -> connect() rejects, says what was answered, and never sends initialize', async (_label, probeAnswer, cause) => { + const { outcome, seen } = await connect(AUTO, probeAnswer); + expect(outcome).toBeInstanceOf(SdkError); + expect((outcome as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((outcome as SdkError).message).toContain(UNUSABLE); + expect(((outcome as Error).cause as Error | undefined)?.name).toBe(cause); + expect(seen).toEqual(['server/discover']); + }); +}); + +describe('#2619: the way out the message points to', () => { + test('a known-legacy verdict skips the probe and connects', async () => { + const { outcome, era, server, seen } = await connect(AUTO, emptyJson, { prior: { kind: 'legacy' } }); + expect(outcome).toBe('connected'); + expect(era).toBe('legacy'); + expect(server).toBe('legacy-behind-gateway'); + expect(seen.slice(0, 2)).toEqual(['initialize', 'notifications/initialized']); + }); + + test('the default mode sends no probe and connects', async () => { + const { outcome, era, seen } = await connect(undefined, emptyJson); + expect(outcome).toBe('connected'); + expect(era).toBe('legacy'); + expect(seen).not.toContain('server/discover'); + }); +}); + +describe('#2619: what must not move', () => { + test('control from the issue: a 400 with an unrecognized body already falls back', async () => { + const { outcome, era } = await connect(AUTO, () => new Response('Bad Request', { status: 400 })); + expect(outcome).toBe('connected'); + expect(era).toBe('legacy'); + }); + + test('a network failure on the probe keeps its own message, and initialize is never sent', async () => { + const { outcome, seen } = await connect(AUTO, () => Promise.reject(new TypeError('fetch failed'))); + expect(outcome).toBeInstanceOf(SdkError); + expect((outcome as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((outcome as SdkError).message).not.toContain(UNUSABLE); + expect(seen).toEqual(['server/discover']); + }); + + test('pin mode on an empty 200 rejects with the same message and keeps Error.cause', async () => { + const { outcome, seen } = await connect({ versionNegotiation: { mode: { pin: '2026-07-28' } } }, emptyJson); + expect(outcome).toBeInstanceOf(SdkError); + expect((outcome as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((outcome as SdkError).message).toContain(UNUSABLE); + expect(((outcome as Error).cause as Error | undefined)?.name).toBe('SyntaxError'); + expect(seen).toEqual(['server/discover']); + }); + + test('a modern-only client on an empty 200 rejects with the same message and keeps Error.cause', async () => { + const { outcome, seen } = await connect( + { versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: ['2026-07-28'] }, + emptyJson + ); + expect(outcome).toBeInstanceOf(SdkError); + expect((outcome as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((outcome as SdkError).message).toContain(UNUSABLE); + expect(((outcome as Error).cause as Error | undefined)?.name).toBe('SyntaxError'); + expect(seen).toEqual(['server/discover']); + }); + + test('a 401 on the probe stays an auth failure', async () => { + const { outcome, seen } = await connect(AUTO, () => new Response('Unauthorized', { status: 401 })); + expect(outcome).toBeInstanceOf(SdkError); + expect((outcome as SdkError).code).toBe(SdkErrorCode.ClientHttpAuthentication); + expect(seen).toEqual(['server/discover']); + }); +}); + +describe('#2619: when every request is answered the same way', () => { + test('connect() rejects after exactly one request, typed', async () => { + const seen: string[] = []; + const fetchStub = (async (_input: Parameters[0], init?: Parameters[1]) => { + seen.push((JSON.parse(String(init?.body)) as { method?: string }).method ?? '(response)'); + return new Response('sign in', { status: 200, headers: { 'content-type': 'text/html' } }); + }) as typeof fetch; + const client = new Client({ name: 'repro-2619', version: '1.0.0' }, AUTO); + const transport = new StreamableHTTPClientTransport(new URL('http://gateway.invalid/mcp'), { fetch: fetchStub }); + const outcome = await client.connect(transport).then( + () => 'connected' as const, + (error: unknown) => error + ); + await client.close().catch(() => {}); + expect(outcome).toBeInstanceOf(SdkError); + expect((outcome as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((outcome as SdkError).message).toContain(UNUSABLE); + expect(seen).toEqual(['server/discover']); + }); +}); diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index 6952af8f76..8b4a8edae8 100644 --- a/packages/client/test/client/sse.test.ts +++ b/packages/client/test/client/sse.test.ts @@ -1783,10 +1783,43 @@ describe('SSEClientTransport', () => { await expect(transport.finishAuth('auth-code')).rejects.toThrow('finishAuth requires an OAuthClientProvider'); }); - it('SSE connect 401 retry does not poison future 401s — onUnauthorized called on each attempt', async () => { + it('SSE connect: a second 401 right after a refresh rejects, onUnauthorized called once', async () => { + await resourceServer.close(); + + let getAttempt = 0; + resourceServer = createServer((req, res) => { + if (req.method === 'GET') { + getAttempt++; + res.writeHead(401).end(); // always 401 + } + }); + resourceBaseUrl = await listenOnRandomPort(resourceServer); + + // Backstop so an unbounded retry ends the test with a clear failure instead of a hang. + let calls = 0; + const authProvider: AuthProvider = { + token: vi.fn(async () => 'still-bad'), + onUnauthorized: vi.fn(async () => { + if (++calls >= 10) throw new Error('backstop: onUnauthorized called 10 times'); + }) + }; + transport = new SSEClientTransport(resourceBaseUrl, { authProvider }); + const onerror = vi.fn(); + transport.onerror = onerror; + + const error = await transport.start().catch(e => e); + expect(error).toBeInstanceOf(SdkHttpError); + expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication); + expect((error as SdkHttpError).status).toBe(401); + expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1); + expect(getAttempt).toBe(2); + expect(onerror).toHaveBeenCalledTimes(1); + }); + + it('SSE connect 401 retry does not poison future 401s — a 401 on a later reconnect refreshes again', async () => { // Regression: _startOrAuth(true) baked isAuthRetry=true into the retry EventSource's // onerror closure, so a subsequent 401 (token expiry on reconnect) would throw - // instead of refreshing. Fix: retry always calls _startOrAuth() fresh. + // instead of refreshing. The retry guard resets once the stream opens. await resourceServer.close(); let getAttempt = 0; @@ -1796,7 +1829,8 @@ describe('SSEClientTransport', () => { return; } getAttempt++; - if (getAttempt < 3) { + // 1: 401, 2: opens then drops, 3: 401 on the automatic reconnect, 4: opens and stays. + if (getAttempt === 1 || getAttempt === 3) { res.writeHead(401).end(); return; } @@ -1805,8 +1839,12 @@ describe('SSEClientTransport', () => { 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive' }); + res.write('retry: 10\n'); res.write('event: endpoint\n'); res.write(`data: ${resourceBaseUrl.href}post\n\n`); + if (getAttempt === 2) { + res.end(); + } }); resourceBaseUrl = await listenOnRandomPort(resourceServer); @@ -1816,17 +1854,17 @@ describe('SSEClientTransport', () => { }; transport = new SSEClientTransport(resourceBaseUrl, { authProvider }); - await transport.start(); // should resolve on attempt 3 + await transport.start(); // resolves on attempt 2 + expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(getAttempt).toBe(4), { timeout: 3000 }); expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(2); - expect(getAttempt).toBe(3); }); it('retry failure during SSE connect fires onerror exactly once', async () => { // Regression: when the retry EventSource rejected, its onerror fired inside, then // the outer .then() rejection handler fired onerror AGAIN for the same error. // Fix: inner retry chains to .then(resolve, reject) — no outer onerror call. - // onUnauthorized's own failure is handled separately and fires onerror once. await resourceServer.close(); resourceServer = createServer((req, res) => { @@ -1836,20 +1874,40 @@ describe('SSEClientTransport', () => { }); resourceBaseUrl = await listenOnRandomPort(resourceServer); - const onUnauthorized: AuthProvider['onUnauthorized'] = vi - .fn() - .mockResolvedValueOnce(undefined) // first call succeeds → triggers retry - .mockRejectedValueOnce(new Error('refresh failed')); // second call (in retry) throws const authProvider: AuthProvider = { token: vi.fn(async () => 'token'), - onUnauthorized + onUnauthorized: vi.fn(async () => {}) + }; + transport = new SSEClientTransport(resourceBaseUrl, { authProvider }); + const onerror = vi.fn(); + transport.onerror = onerror; + + await expect(transport.start()).rejects.toThrow('Server returned 401 after re-authentication'); + expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1); + expect(onerror).toHaveBeenCalledTimes(1); + expect(onerror.mock.calls[0]![0].message).toBe('Server returned 401 after re-authentication'); + }); + + it('a failing onUnauthorized during SSE connect fires onerror exactly once', async () => { + await resourceServer.close(); + + resourceServer = createServer((req, res) => { + if (req.method === 'GET') { + res.writeHead(401).end(); // always 401 + } + }); + resourceBaseUrl = await listenOnRandomPort(resourceServer); + + const authProvider: AuthProvider = { + token: vi.fn(async () => 'token'), + onUnauthorized: vi.fn().mockRejectedValueOnce(new Error('refresh failed')) }; transport = new SSEClientTransport(resourceBaseUrl, { authProvider }); const onerror = vi.fn(); transport.onerror = onerror; await expect(transport.start()).rejects.toThrow('refresh failed'); - expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(2); + expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1); expect(onerror).toHaveBeenCalledTimes(1); expect(onerror.mock.calls[0]![0].message).toBe('refresh failed'); }); diff --git a/packages/client/test/client/versionNegotiation.test.ts b/packages/client/test/client/versionNegotiation.test.ts index 60c9a2aaba..ed87a774c3 100644 --- a/packages/client/test/client/versionNegotiation.test.ts +++ b/packages/client/test/client/versionNegotiation.test.ts @@ -1390,3 +1390,81 @@ describe('probe window preserves pre-set transport handlers', () => { await client.close(); }); }); + +// A probe answered 2xx without a usable reply is not era evidence: connect() rejects and says so. +describe('probe unusable-reply classification', () => { + /** Rejects the probe send with `probeError`, then serves legacy initialize. */ + class UnusableReplyTransport extends ScriptedTransport { + constructor(private readonly probeError: Error) { + super(legacyServerScript); + } + + override async send(message: JSONRPCMessage): Promise { + if (isJSONRPCRequest(message) && message.method === 'server/discover') { + throw this.probeError; + } + await super.send(message); + } + } + + const emptyJsonBody = (): Error => { + try { + JSON.parse(''); + throw new Error('unreachable'); + } catch (error) { + return error as Error; + } + }; + const unusableReplies: Array<[string, () => Error]> = [ + ['an empty 2xx body parsed as application/json (a gateway swallowing the probe)', emptyJsonBody], + [ + 'a bare 204 / text-plain answer the transport does not accept', + () => + new SdkError(SdkErrorCode.ClientHttpUnexpectedContent, 'Unexpected content type: text/plain', { + contentType: 'text/plain' + }) + ] + ]; + const rejectionOf = (connecting: Promise) => + connecting.then( + () => { + throw new Error('connect unexpectedly resolved'); + }, + (error: unknown) => error + ); + + test.each(unusableReplies)('%s rejects connect() with its own message and never sends initialize', async (_label, makeError) => { + const transport = new UnusableReplyTransport(makeError()); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + const rejection = await rejectionOf(client.connect(transport)); + + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((rejection as SdkError).message).toContain('the server answered with an unusable reply'); + expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(false); + }); + + test('a known-legacy verdict skips the probe, so the same server connects', async () => { + const transport = new UnusableReplyTransport(emptyJsonBody()); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + await client.connect(transport, { prior: { kind: 'legacy' } }); + + expect(requests(transport.sent).some(r => r.method === 'server/discover')).toBe(false); + expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(true); + await client.close(); + }); + + test('a genuine network failure keeps its own message (not reported as an unusable reply)', async () => { + const transport = new UnusableReplyTransport(new TypeError('fetch failed')); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + const rejection = await rejectionOf(client.connect(transport)); + + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((rejection as SdkError).message).not.toContain('unusable reply'); + expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(false); + }); +}); diff --git a/packages/codemod/package.json b/packages/codemod/package.json index 030315eaeb..cb8fe9b436 100644 --- a/packages/codemod/package.json +++ b/packages/codemod/package.json @@ -2,7 +2,7 @@ "name": "@modelcontextprotocol/codemod", "version": "2.2.0", "description": "Codemod to migrate MCP TypeScript SDK code from v1 to v2", - "license": "MIT", + "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index 1febcabe68..07754feab9 100644 --- a/packages/core-internal/package.json +++ b/packages/core-internal/package.json @@ -3,7 +3,7 @@ "private": true, "version": "2.0.2", "description": "Model Context Protocol implementation for TypeScript - Core package", - "license": "MIT", + "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", diff --git a/packages/core-internal/src/shared/inputRequired.ts b/packages/core-internal/src/shared/inputRequired.ts index b2736e23b6..bf518e39b0 100644 --- a/packages/core-internal/src/shared/inputRequired.ts +++ b/packages/core-internal/src/shared/inputRequired.ts @@ -113,13 +113,14 @@ function buildInputRequired(spec: InputRequiredSpec): InputRequiredResult { * @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) { + * const confirmationSchema = z.object({ confirm: z.boolean() }); + * const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', confirmationSchema); + * if (confirmed?.confirm !== true) { * return inputRequired({ * inputRequests: { * confirm: inputRequired.elicit({ * message: `Deploy to ${env}?`, - * requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + * requestedSchema: confirmationSchema * }) * } * }); diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 8e0bc04034..9d60be8efe 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -50,7 +50,14 @@ import type { StandardSchemaV1 } from '../util/standardSchema'; import { isStandardSchema, validateStandardSchema } from '../util/standardSchema'; import { bootstrapOutboundCodec } from '../wire/bootstrap'; import type { LiftedWireMaterial, WireCodec } from '../wire/codec'; -import { classifiedWireEra, codecForVersion, isSpecNotificationMethod, isSpecRequestMethod, MODERN_WIRE_REVISION } from '../wire/codec'; +import { + classifiedWireEra, + codecForVersion, + isExtensionReusedRequestMethod, + isSpecNotificationMethod, + isSpecRequestMethod, + MODERN_WIRE_REVISION +} from '../wire/codec'; import { manualInputRequiredValue, partitionInputResponses } from './inputRequiredEngine'; import type { Transport, TransportSendOptions } from './transport'; @@ -559,6 +566,8 @@ export abstract class Protocol { private _transport?: Transport; private _requestMessageId = 0; private _requestHandlers: Map Promise> = new Map(); + /** Methods registered with an explicit schema; the era gate in `_onrequest` reads it for the Tasks extension names. */ + private _customSchemaRequestMethods = new Set(); private _requestHandlerAbortControllers: Map = new Map(); private _notificationHandlers: Map Promise> = new Map(); private _responseHandlers: Map void> = new Map(); @@ -1000,7 +1009,12 @@ export abstract class Protocol { // 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)) { + // Exception: the Tasks extension names (SEP-2663), when registered with an explicit schema. + if ( + isSpecRequestMethod(request.method) && + !codec.hasRequestMethod(request.method) && + !(isExtensionReusedRequestMethod(request.method) && this._customSchemaRequestMethods.has(request.method)) + ) { sendErrorResponse(ProtocolErrorCode.MethodNotFound, 'Method not found'); return; } @@ -1270,10 +1284,12 @@ export abstract class Protocol { ): Promise>; request(request: Request, schemaOrOptions?: StandardSchemaV1 | RequestOptions, maybeOptions?: RequestOptions): Promise { const codec = this._resolveOutboundCodec(request.method); - this._assertOutboundRequestInEra(codec, request.method); if (isStandardSchema(schemaOrOptions)) { + // Only the Tasks extension names skip the era gate here; every other spec method stays gated. + if (!isExtensionReusedRequestMethod(request.method)) this._assertOutboundRequestInEra(codec, request.method); return this._requestWithSchemaViaCodec(codec, request, schemaOrOptions, maybeOptions); } + this._assertOutboundRequestInEra(codec, request.method); 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().`); @@ -1751,6 +1767,13 @@ export abstract class Protocol { throw new TypeError('setRequestHandler: handler is required'); } + // The era gate reads how the handler was registered; a method-keyed re-registration ends the exemption. + if (typeof schemasOrHandler === 'function') { + this._customSchemaRequestMethods.delete(method); + } else { + this._customSchemaRequestMethods.add(method); + } + this._requestHandlers.set(method, this._wrapHandler(method, stored)); } @@ -1787,6 +1810,7 @@ export abstract class Protocol { */ removeRequestHandler(method: RequestMethod | string): void { this._requestHandlers.delete(method); + this._customSchemaRequestMethods.delete(method); } /** diff --git a/packages/core-internal/src/types/README.md b/packages/core-internal/src/types/README.md index fda450e8e9..c0aba1bd43 100644 --- a/packages/core-internal/src/types/README.md +++ b/packages/core-internal/src/types/README.md @@ -8,11 +8,9 @@ They are reference-only test oracles: the comparison suites in `packages/core-in ## 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. + 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 atomically with every checked-in oracle derived from that revision. -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. +2. **Draft anchors float only while they have an explicit consumer.** An unreleased revision may track the spec repository's `schema/draft/schema.ts`, but refreshes must go through reviewed PRs and update all derived oracles atomically. No currently supported revision uses this mode. 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. @@ -20,7 +18,7 @@ They are reference-only test oracles: the comparison suites in `packages/core-in 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-internal/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 + **This clause is OPERATIVE.** The vendored twins are the per-revision `schema.json` copies under `packages/core-internal/test/corpus/schema-twins/` (`.schema.json` + `manifest.json` recording each twin's 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 refresh the matching upstream + `schema//schema.json` from that twin's recorded source commit and update `manifest.json` in the same change; 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-internal/src/types/spec.types.2026-07-28.ts b/packages/core-internal/src/types/spec.types.2026-07-28.ts index f4430b850f..36ce3b043d 100644 --- a/packages/core-internal/src/types/spec.types.2026-07-28.ts +++ b/packages/core-internal/src/types/spec.types.2026-07-28.ts @@ -2,8 +2,8 @@ * This file is automatically generated from the Model Context Protocol specification. * * Source: https://github.com/modelcontextprotocol/modelcontextprotocol - * Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/draft/schema.ts - * Last updated from commit: 71e306956a4959c9655e5036be215d41986596e6 + * Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/2026-07-28/schema.ts + * Last updated from commit: 271ecc9accafdd9b83a3c869fa67c22953b2af80 * * 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 @@ -55,7 +55,7 @@ export const JSONRPC_VERSION = '2.0'; * - Unless empty, MUST start and end with an alphanumeric character (`[a-z0-9A-Z]`). * - Interior characters may be alphanumeric, hyphens (`-`), underscores (`_`), or dots (`.`). * - * @see [General fields: `_meta`](/specification/draft/basic/index#meta) for more details. + * @see [General fields: `_meta`](/specification/2026-07-28/basic/index#meta) for more details. * @category Common Types */ export type MetaObject = Record; @@ -64,7 +64,7 @@ export type MetaObject = Record; * Extends {@link MetaObject} with additional request-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. + * @see [General fields: `_meta`](/specification/2026-07-28/basic/index#meta) for more details. * @category Common Types */ export interface RequestMetaObject extends MetaObject { @@ -121,7 +121,7 @@ export interface RequestMetaObject extends MetaObject { * 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. + * @see [General fields: `_meta`](/specification/2026-07-28/basic/index#meta) for more details. * @category Common Types */ export interface NotificationMetaObject extends MetaObject { @@ -144,7 +144,7 @@ export interface NotificationMetaObject extends MetaObject { * Extends {@link MetaObject} with additional result-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. + * @see [General fields: `_meta`](/specification/2026-07-28/basic/index#meta) for more details. * @category Common Types */ export interface ResultMetaObject extends MetaObject { @@ -1317,7 +1317,7 @@ export interface SubscriptionsListenRequest extends JSONRPCRequest { * @see {@link MetaObject} for key naming rules and reserved prefixes. * @category `subscriptions/listen` */ -export interface SubscriptionsListenResultMeta extends ResultMetaObject { +export interface SubscriptionsListenResultMetaObject extends ResultMetaObject { /** * Identifies the subscription stream this response closes, so the client can * correlate it with the originating subscription — mirroring the same key on @@ -1341,7 +1341,20 @@ export interface SubscriptionsListenResultMeta extends ResultMetaObject { * @category `subscriptions/listen` */ export interface SubscriptionsListenResult extends Result { - _meta: SubscriptionsListenResultMeta; + _meta: SubscriptionsListenResultMetaObject; +} + +/** + * A successful response from the server for a {@link SubscriptionsListenRequest | subscriptions/listen} + * request, sent when the server tears the subscription down gracefully. + * + * @example Subscription closed gracefully response + * {@includeCode ./examples/SubscriptionsListenResultResponse/listen-closed-response.json} + * + * @category `subscriptions/listen` + */ +export interface SubscriptionsListenResultResponse extends JSONRPCResultResponse { + result: SubscriptionsListenResult; } /** diff --git a/packages/core-internal/src/wire/codec.ts b/packages/core-internal/src/wire/codec.ts index cf545c2ebc..03f0933d94 100644 --- a/packages/core-internal/src/wire/codec.ts +++ b/packages/core-internal/src/wire/codec.ts @@ -33,9 +33,10 @@ * 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 + * a deleted spec method (e.g. `tasks/result`) 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. + * Exception: `isExtensionReusedRequestMethod`, the Tasks extension names sent or served with an explicit schema. * * Everything in `wire/` is internal to the bundled, `private: true` core — * nothing per-revision is public surface, and nothing here may ever be @@ -335,4 +336,9 @@ export function isSpecNotificationMethod(method: string): boolean { return ALL_CODECS.some(codec => codec.hasNotificationMethod(method)); } +/** The names SEP-2663 re-defines for the Tasks extension on the era that removed them from core. */ +export function isExtensionReusedRequestMethod(method: string): boolean { + return method === 'tasks/get' || method === 'tasks/cancel'; +} + const ALL_CODECS: readonly WireCodec[] = [rev2025Codec, rev2026Codec]; diff --git a/packages/core-internal/src/wire/rev2026-07-28/buildSchemas.ts b/packages/core-internal/src/wire/rev2026-07-28/buildSchemas.ts index 6831062fd3..f286b3cffa 100644 --- a/packages/core-internal/src/wire/rev2026-07-28/buildSchemas.ts +++ b/packages/core-internal/src/wire/rev2026-07-28/buildSchemas.ts @@ -1070,7 +1070,7 @@ function build() { const SubscriptionsListenRequestSchema = wireRequest('subscriptions/listen', subscriptionsListenParamsShape); /** - * Anchor SubscriptionsListenResultMeta — required subscriptionId stamp on + * Anchor SubscriptionsListenResultMetaObject — required subscriptionId stamp on * the graceful-close result. Extends `ResultMetaObject` since spec PR * #3002 (composed, so the serverInfo key and its leniency stay single-sourced). */ @@ -1276,6 +1276,7 @@ function build() { const ReadResourceResultResponseSchema = wireResultResponse(z.union([ReadResourceResultSchema, InputRequiredResultSchema])); const CompleteResultResponseSchema = wireResultResponse(CompleteResultSchema); const DiscoverResultResponseSchema = wireResultResponse(DiscoverResultSchema); + const SubscriptionsListenResultResponseSchema = wireResultResponse(SubscriptionsListenResultSchema); return { JSONValueSchema, @@ -1411,7 +1412,8 @@ function build() { ListResourceTemplatesResultResponseSchema, ReadResourceResultResponseSchema, CompleteResultResponseSchema, - DiscoverResultResponseSchema + DiscoverResultResponseSchema, + SubscriptionsListenResultResponseSchema }; } diff --git a/packages/core-internal/src/wire/rev2026-07-28/schemas.ts b/packages/core-internal/src/wire/rev2026-07-28/schemas.ts index b342ded914..d1ff969736 100644 --- a/packages/core-internal/src/wire/rev2026-07-28/schemas.ts +++ b/packages/core-internal/src/wire/rev2026-07-28/schemas.ts @@ -156,3 +156,4 @@ export const ListResourceTemplatesResultResponseSchema = s.ListResourceTemplates export const ReadResourceResultResponseSchema = s.ReadResourceResultResponseSchema; export const CompleteResultResponseSchema = s.CompleteResultResponseSchema; export const DiscoverResultResponseSchema = s.DiscoverResultResponseSchema; +export const SubscriptionsListenResultResponseSchema = s.SubscriptionsListenResultResponseSchema; diff --git a/packages/core-internal/test/corpus/fixtures/2026-07-28/SubscriptionsListenResultResponse/listen-closed-response.json b/packages/core-internal/test/corpus/fixtures/2026-07-28/SubscriptionsListenResultResponse/listen-closed-response.json new file mode 100644 index 0000000000..9441979a42 --- /dev/null +++ b/packages/core-internal/test/corpus/fixtures/2026-07-28/SubscriptionsListenResultResponse/listen-closed-response.json @@ -0,0 +1,10 @@ +{ + "jsonrpc": "2.0", + "id": "listen-1", + "result": { + "resultType": "complete", + "_meta": { + "io.modelcontextprotocol/subscriptionId": "listen-1" + } + } +} diff --git a/packages/core-internal/test/corpus/fixtures/2026-07-28/manifest.json b/packages/core-internal/test/corpus/fixtures/2026-07-28/manifest.json index eeeae659cd..b47980fe67 100644 --- a/packages/core-internal/test/corpus/fixtures/2026-07-28/manifest.json +++ b/packages/core-internal/test/corpus/fixtures/2026-07-28/manifest.json @@ -2,12 +2,12 @@ "revision": "2026-07-28", "source": { "repo": "modelcontextprotocol/modelcontextprotocol", - "path": "schema/draft/examples", - "commit": "71e306956a4959c9655e5036be215d41986596e6" + "path": "schema/2026-07-28/examples", + "commit": "271ecc9accafdd9b83a3c869fa67c22953b2af80" }, "regenerate": "pnpm fetch:spec-examples --spec-dir # or [sha] to fetch from GitHub", - "directoryCount": 87, - "fileCount": 128, + "directoryCount": 88, + "fileCount": 129, "directories": { "AudioContent": [ "audio-wav-content.json" @@ -273,6 +273,9 @@ "SubscriptionsListenResult": [ "listen-closed.json" ], + "SubscriptionsListenResultResponse": [ + "listen-closed-response.json" + ], "TextContent": [ "text-content.json" ], diff --git a/packages/core-internal/test/corpus/schema-twins/2026-07-28.schema.json b/packages/core-internal/test/corpus/schema-twins/2026-07-28.schema.json index cc44564e33..213c58f6d9 100644 --- a/packages/core-internal/test/corpus/schema-twins/2026-07-28.schema.json +++ b/packages/core-internal/test/corpus/schema-twins/2026-07-28.schema.json @@ -3400,7 +3400,7 @@ "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" + "$ref": "#/$defs/SubscriptionsListenResultMetaObject" }, "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\"`.", @@ -3413,7 +3413,7 @@ ], "type": "object" }, - "SubscriptionsListenResultMeta": { + "SubscriptionsListenResultMetaObject": { "description": "Extends {@link ResultMetaObject} with the subscription-stream identifier carried by a\n{@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply.", "properties": { "io.modelcontextprotocol/serverInfo": { @@ -3430,6 +3430,27 @@ ], "type": "object" }, + "SubscriptionsListenResultResponse": { + "description": "A successful response from the server for a {@link SubscriptionsListenRequestsubscriptions/listen}\nrequest, sent when the server tears the subscription down gracefully.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/SubscriptionsListenResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, "TextContent": { "description": "Text provided to or from an LLM.", "properties": { diff --git a/packages/core-internal/test/corpus/schema-twins/manifest.json b/packages/core-internal/test/corpus/schema-twins/manifest.json index 9047083294..b2f097ce2c 100644 --- a/packages/core-internal/test/corpus/schema-twins/manifest.json +++ b/packages/core-internal/test/corpus/schema-twins/manifest.json @@ -1,16 +1,17 @@ { - "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-internal/src/types/README.md lifecycle rule 4).", + "comment": "Vendored schema.json twins (TEST-ONLY conformance oracles; never bundled, never runtime). RAW upstream bytes - never reformat: each file is locked to its sourceCommit/sha256/bytes by schemaTwinConformance. Refresh via `pnpm fetch:schema-twins [sha]`; with no SHA each twin uses its recorded sourceCommit.", "source": { - "repository": "modelcontextprotocol/modelcontextprotocol", - "commit": "71e306956a4959c9655e5036be215d41986596e6" + "repository": "modelcontextprotocol/modelcontextprotocol" }, "files": { "2026-07-28": { - "sha256": "9281c4890630e2d1e61792fa23b4084c4ea360cd58519610cd050545ab7b8708", - "bytes": 180695, - "upstreamPath": "schema/draft/schema.json" + "sourceCommit": "271ecc9accafdd9b83a3c869fa67c22953b2af80", + "sha256": "ef70b61f99b6d2e5e3b46863822eab08dff6a45bedc7a08914e0e5b133f40203", + "bytes": 181474, + "upstreamPath": "schema/2026-07-28/schema.json" }, "2025-11-25": { + "sourceCommit": "71e306956a4959c9655e5036be215d41986596e6", "sha256": "7b2d96fd95efd2216aa953606b83f5a740ddeaa5ebd3a5d27b45a8296545a118", "bytes": 174326, "upstreamPath": "schema/2025-11-25/schema.json" diff --git a/packages/core-internal/test/corpus/specCorpus.test.ts b/packages/core-internal/test/corpus/specCorpus.test.ts index 64a5bd1fb2..5c8a0393fa 100644 --- a/packages/core-internal/test/corpus/specCorpus.test.ts +++ b/packages/core-internal/test/corpus/specCorpus.test.ts @@ -3,8 +3,8 @@ * * Two corpora, one harness: * - * - `fixtures/2026-07-28/` is VENDORED from the spec repository's draft - * example set (`schema/draft/examples/`), regenerated only via + * - `fixtures/2026-07-28/` is VENDORED from the released spec repository + * example set (`schema/2026-07-28/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. @@ -59,7 +59,7 @@ const ERROR_OBJECT_DIRS = new Set([ ]); /** - * Draft (2026-07-28) vocabulary the SDK does not model yet, at directory + * 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. @@ -72,7 +72,7 @@ const PENDING_2026: Record = { /** * 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 + * revision-specific 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 = { @@ -172,12 +172,14 @@ 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 }; + source: { path: string; commit: string }; directoryCount: number; fileCount: number; directories: Record; }; expect(manifest.revision).toBe('2026-07-28'); + expect(manifest.source.path).toBe('schema/2026-07-28/examples'); + expect(manifest.source.commit).toBe('271ecc9accafdd9b83a3c869fa67c22953b2af80'); const dirs = listTypeDirs('2026-07-28'); expect(dirs).toEqual(Object.keys(manifest.directories).sort()); @@ -186,8 +188,8 @@ describe('corpus inventory pins', () => { // 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); + expect(manifest.directoryCount).toBe(88); + expect(manifest.fileCount).toBe(129); }); test('the frozen 2025-11-25 corpus keeps its inventory', () => { diff --git a/packages/core-internal/test/spec.types.2026-07-28.test.ts b/packages/core-internal/test/spec.types.2026-07-28.test.ts index 4bdc3d11fc..be1aa51eab 100644 --- a/packages/core-internal/test/spec.types.2026-07-28.test.ts +++ b/packages/core-internal/test/spec.types.2026-07-28.test.ts @@ -91,6 +91,7 @@ type WSubscriptionsAcknowledgedNotification = z4.infer; type WSubscriptionsListenResultMeta = z4.infer; +type WSubscriptionsListenResultResponse = z4.infer; // The anchor's ClientRequest union, composed from the era module's wire requests. type WClientRequest = | WCompleteRequest @@ -804,7 +805,11 @@ const wireParityChecks = { sdk = spec; spec = sdk; }, - SubscriptionsListenResultMeta: (sdk: WSubscriptionsListenResultMeta, spec: SpecTypes.SubscriptionsListenResultMeta) => { + SubscriptionsListenResultMetaObject: (sdk: WSubscriptionsListenResultMeta, spec: SpecTypes.SubscriptionsListenResultMetaObject) => { + sdk = spec; + spec = sdk; + }, + SubscriptionsListenResultResponse: (sdk: WSubscriptionsListenResultResponse, spec: SpecTypes.SubscriptionsListenResultResponse) => { sdk = spec; spec = sdk; }, @@ -874,8 +879,10 @@ describe('Spec Types (2026-07-28)', () => { expect(specTypes).toContain('InputRequiredResult'); expect(specTypes).toContain('SubscriptionsListenRequest'); expect(specTypes).toContain('SubscriptionsListenResult'); + expect(specTypes).toContain('SubscriptionsListenResultMetaObject'); + expect(specTypes).toContain('SubscriptionsListenResultResponse'); expect(specTypes).toContain('ResultMetaObject'); - expect(specTypes).toHaveLength(154); + expect(specTypes).toHaveLength(155); }); it('should only allowlist types that exist in the 2026-07-28 schema', () => { diff --git a/packages/core-internal/test/wire/eraGates.test.ts b/packages/core-internal/test/wire/eraGates.test.ts index fb2eb26d4e..fdba857ce4 100644 --- a/packages/core-internal/test/wire/eraGates.test.ts +++ b/packages/core-internal/test/wire/eraGates.test.ts @@ -11,9 +11,13 @@ * 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 + * - inbound: a handler cannot shadow a deleted spec method across eras — + * `ping` on a modern-era instance still answers −32601 BY ABSENCE, with + * or without an explicit schema. The one exception (issue #2598): the + * Tasks extension, SEP-2663, keeps `tasks/get` and `tasks/cancel` after + * 2026-07-28 moved tasks out of core, so an EXPLICIT-SCHEMA handler + * (`setRequestHandler(method, schemas, handler)`) for those two names is + * served on a modern-era instance. 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 @@ -109,31 +113,161 @@ const resultOf = (msg: JSONRPCMessage | undefined) => (msg as { result?: Record< 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. + // An explicit-schema (3-arg) handler for a name the Tasks extension + // keeps (#2598): it is served on every era. 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 () => { + test('a modern-era instance still serves tasks/get through an explicit-schema handler (#2598)', 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. + // handoff check accepts it; the era gate no longer answers by + // absence when an explicit-schema handler is registered. + 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(true); + expect(resultOf(h.sent[0])).toBeDefined(); + }); + + test('a modern-era instance still answers −32601 BY ABSENCE for a deleted spec method with no handler registered', async () => { + const h = await harness({ era: '2026-07-28' }); + 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' }); }); + // The built-in `ping` handler (registered via the typed 2-arg overload + // in the `Protocol` constructor) demonstrates the typed path stays fully + // era-gated — see 'ping on a modern-era instance is −32601 by absence' + // below. + + test('a modern-era instance serves tasks/cancel through an explicit-schema handler too (#2598)', async () => { + let handlerRan = false; + const h = await harness({ + era: '2026-07-28', + setup: receiver => + receiver.setRequestHandler('tasks/cancel', { params: z.looseObject({ taskId: z.string() }) }, () => { + handlerRan = true; + return {} as Result; + }) + }); + + h.deliver( + { jsonrpc: '2.0', id: 1, method: 'tasks/cancel', params: { taskId: 't-1', _meta: { ...ENVELOPE } } } as JSONRPCMessage, + MODERN + ); + await h.flush(); + + expect(handlerRan).toBe(true); + expect(resultOf(h.sent[0])).toBeDefined(); + }); + + test.each(['ping', 'initialize', 'logging/setLevel', 'resources/subscribe', 'tasks/result', 'tasks/list'])( + 'the exemption is for the Tasks extension names only: an explicit-schema handler for %s stays −32601 on the modern era', + async method => { + let handlerRan = false; + const h = await harness({ + era: '2026-07-28', + setup: receiver => + receiver.setRequestHandler(method, { params: z.looseObject({}) }, () => { + handlerRan = true; + return {} as Result; + }) + }); + + h.deliver({ jsonrpc: '2.0', id: 1, method, params: { _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN); + await h.flush(); + + expect(handlerRan).toBe(false); + expect(errorOf(h.sent[0])).toMatchObject({ code: -32601, message: 'Method not found' }); + } + ); + + test('a method-keyed handler for a deleted spec method stays −32601 on the modern era: only an explicit schema is exempt', async () => { + let handlerRan = false; + const h = await harness({ + era: '2026-07-28', + setup: receiver => + (receiver.setRequestHandler as (method: string, handler: () => Result) => void)('tasks/get', () => { + handlerRan = true; + return {} as Result; + }) + }); + + 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(errorOf(h.sent[0])).toMatchObject({ code: -32601, message: 'Method not found' }); + }); + + test.each([ + [ + 're-registered method-keyed', + (receiver: TestProtocol) => + (receiver.setRequestHandler as (method: string, handler: () => Result) => void)('tasks/get', () => ({}) as Result) + ], + ['removed', (receiver: TestProtocol) => receiver.removeRequestHandler('tasks/get')] + ])('the exemption follows the registration: an explicit-schema handler that is %s answers −32601 again', async (_label, undo) => { + let served = false; + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + registerTasksGetHandler(() => (served = true))(receiver); + undo(receiver); + // A catch-all must not pick the deleted spec method up either. + receiver.fallbackRequestHandler = async () => { + served = true; + return {} as Result; + }; + } + }); + + h.deliver( + { jsonrpc: '2.0', id: 1, method: 'tasks/get', params: { taskId: 't-1', _meta: { ...ENVELOPE } } } as JSONRPCMessage, + MODERN + ); + await h.flush(); + + expect(served).toBe(false); + expect(errorOf(h.sent[0])).toMatchObject({ code: -32601, message: 'Method not found' }); + }); + + test('the exemption is for the Tasks extension names only: a legacy-era instance still answers a 2026-only name with -32601', async () => { + let handlerRan = false; + const h = await harness({ + setup: receiver => + receiver.setRequestHandler('subscriptions/listen', { params: z.looseObject({}) }, () => { + handlerRan = true; + return {} as Result; + }) + }); + + h.deliver({ jsonrpc: '2.0', id: 1, method: 'subscriptions/listen', params: {} } as JSONRPCMessage); + await h.flush(); + + expect(handlerRan).toBe(false); + expect(errorOf(h.sent[0])).toMatchObject({ code: -32601 }); + }); + 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)) }); @@ -534,6 +668,55 @@ describe('outbound era gates — typed local error before the transport', () => expect(h.sent).toHaveLength(0); }); + test('an explicit schema does not open the other 2025-only spec methods: request() still fails locally on a 2026-era instance', async () => { + const h = await harness({ era: '2026-07-28' }); + + for (const method of [ + 'ping', + 'initialize', + 'logging/setLevel', + 'resources/subscribe', + 'tasks/result', + 'tasks/list', + 'roots/list' + ]) { + const attempt = () => h.receiver.request({ method }, z.looseObject({})); + expect(attempt, method).toThrow(SdkError); + try { + attempt(); + } catch (error) { + expect((error as SdkError).code, method).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + } + } + expect(h.sent).toHaveLength(0); + }); + + test('the public request() overload sends tasks/cancel with an explicit schema on a 2026-era instance too (#2598)', async () => { + const h = await harness({ era: '2026-07-28' }); + + const pending = h.receiver.request({ method: 'tasks/cancel', params: { taskId: 't-1' } }, z.looseObject({})); + await h.flush(); + + expect(h.sent).toHaveLength(1); + expect(h.sent[0]).toMatchObject({ method: 'tasks/cancel', params: { taskId: 't-1' } }); + pending.catch(() => {}); + }); + + test('the public request() overload sends tasks/get with an explicit schema even on a 2026-era instance (#2598)', async () => { + const h = await harness({ era: '2026-07-28' }); + + const pending = h.receiver.request({ method: 'tasks/get', params: { taskId: 't-1' } }, z.looseObject({})); + await h.flush(); + + // Reached the transport (unlike the typed-dispatch case above, which + // never gets past the local era gate) — no peer is listening in this + // harness, so the request itself is left pending; asserting on + // `h.sent` is enough to prove it was NOT rejected locally. + expect(h.sent).toHaveLength(1); + expect(h.sent[0]).toMatchObject({ method: 'tasks/get', params: { taskId: 't-1' } }); + pending.catch(() => {}); + }); + 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 diff --git a/packages/core-internal/test/wire/schemaTwinConformance.test.ts b/packages/core-internal/test/wire/schemaTwinConformance.test.ts index 6d7945a425..05c4433c5a 100644 --- a/packages/core-internal/test/wire/schemaTwinConformance.test.ts +++ b/packages/core-internal/test/wire/schemaTwinConformance.test.ts @@ -32,8 +32,8 @@ const FIXTURES_ROOT = join(__dirname, '../corpus/fixtures'); const TWINS_ROOT = join(__dirname, '../corpus/schema-twins'); interface TwinManifest { - source: { repository: string; commit: string }; - files: Record; + source: { repository: string }; + files: Record; } const TWIN_MANIFEST = JSON.parse(readFileSync(join(TWINS_ROOT, 'manifest.json'), 'utf8')) as TwinManifest; @@ -47,6 +47,7 @@ describe('twin provenance integrity (the manifest lock)', () => { // 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]!; + expect(entry.sourceCommit).toMatch(/^[0-9a-f]{40}$/); 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( diff --git a/packages/core/package.json b/packages/core/package.json index a321becd5a..8ba5d6c88b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -2,7 +2,7 @@ "name": "@modelcontextprotocol/core", "version": "2.2.0", "description": "Model Context Protocol for TypeScript — public Zod schemas (spec + OAuth/OpenID)", - "license": "MIT", + "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", diff --git a/packages/middleware/express/package.json b/packages/middleware/express/package.json index 92fe653f5c..7cc6b47a6e 100644 --- a/packages/middleware/express/package.json +++ b/packages/middleware/express/package.json @@ -3,7 +3,7 @@ "private": false, "version": "2.0.1", "description": "Express adapters for the Model Context Protocol TypeScript server SDK - Express middleware", - "license": "MIT", + "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", diff --git a/packages/middleware/express/src/express.ts b/packages/middleware/express/src/express.ts index 0d3137f549..1cd060858a 100644 --- a/packages/middleware/express/src/express.ts +++ b/packages/middleware/express/src/express.ts @@ -33,6 +33,11 @@ export interface CreateMcpExpressAppOptions { * 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`. + * + * A client that runs as a browser extension sends its extension ID as the origin + * hostname: list that ID to admit one extension. A lowercase entry of the form + * `://*` (e.g. `'moz-extension://*'`) admits every extension of that browser. + * `http://*` and `https://*` are not honoured. */ allowedOrigins?: string[]; diff --git a/packages/middleware/express/src/middleware/originValidation.ts b/packages/middleware/express/src/middleware/originValidation.ts index d92513ae6c..dbac2b6618 100644 --- a/packages/middleware/express/src/middleware/originValidation.ts +++ b/packages/middleware/express/src/middleware/originValidation.ts @@ -11,7 +11,7 @@ import type { NextFunction, Request, RequestHandler, Response } from 'express'; * `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). + * @param allowedOriginHostnames - List of allowed origin hostnames (without scheme or port), or `://*` entries. * For IPv6, provide the address with brackets (e.g., `[::1]`). * @returns Express middleware function * diff --git a/packages/middleware/express/test/originValidation.test.ts b/packages/middleware/express/test/originValidation.test.ts index cfcbaba806..654ed86c4d 100644 --- a/packages/middleware/express/test/originValidation.test.ts +++ b/packages/middleware/express/test/originValidation.test.ts @@ -118,6 +118,26 @@ describe('@modelcontextprotocol/express origin validation', () => { expect(noOrigin.status).toBe(200); }); + test('admits a browser-extension origin once its scheme is listed in allowedOrigins', async () => { + const extension = 'chrome-extension://abcdefghijklmnopabcdefghijklmnop'; + + const byDefault = createMcpExpressApp(); + byDefault.get('/health', (_req, res) => { + res.json({ ok: true }); + }); + const rejected1 = await supertest(byDefault).get('/health').set('Origin', extension); + expect(rejected1.status).toBe(403); + + const withScheme = createMcpExpressApp({ allowedOrigins: ['localhost', 'chrome-extension://*'] }); + withScheme.get('/health', (_req, res) => { + res.json({ ok: true }); + }); + const admitted2 = await supertest(withScheme).get('/health').set('Origin', extension); + expect(admitted2.status).toBe(200); + const rejected3 = await supertest(withScheme).get('/health').set('Origin', 'https://evil.example.com'); + expect(rejected3.status).toBe(403); + }); + test('an explicit allowedOrigins list replaces the default allowlist (validation stays armed)', async () => { const app = createMcpExpressApp({ host: '0.0.0.0', diff --git a/packages/middleware/fastify/package.json b/packages/middleware/fastify/package.json index d15134ef19..09ad7230dd 100644 --- a/packages/middleware/fastify/package.json +++ b/packages/middleware/fastify/package.json @@ -3,7 +3,7 @@ "private": false, "version": "2.0.0", "description": "Fastify adapters for the Model Context Protocol TypeScript server SDK - Fastify middleware", - "license": "MIT", + "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", diff --git a/packages/middleware/fastify/src/fastify.ts b/packages/middleware/fastify/src/fastify.ts index b010188250..ec27feefa3 100644 --- a/packages/middleware/fastify/src/fastify.ts +++ b/packages/middleware/fastify/src/fastify.ts @@ -33,6 +33,11 @@ export interface CreateMcpFastifyAppOptions { * 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`. + * + * A client that runs as a browser extension sends its extension ID as the origin + * hostname: list that ID to admit one extension. A lowercase entry of the form + * `://*` (e.g. `'moz-extension://*'`) admits every extension of that browser. + * `http://*` and `https://*` are not honoured. */ allowedOrigins?: string[]; } diff --git a/packages/middleware/fastify/src/middleware/originValidation.ts b/packages/middleware/fastify/src/middleware/originValidation.ts index aad855885c..f8e68f6f52 100644 --- a/packages/middleware/fastify/src/middleware/originValidation.ts +++ b/packages/middleware/fastify/src/middleware/originValidation.ts @@ -11,7 +11,7 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; * `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). + * @param allowedOriginHostnames - List of allowed origin hostnames (without scheme or port), or `://*` entries. * For IPv6, provide the address with brackets (e.g., `[::1]`). * @returns Fastify onRequest hook handler * diff --git a/packages/middleware/fastify/test/originValidation.test.ts b/packages/middleware/fastify/test/originValidation.test.ts index f18fe247b3..29f2ead844 100644 --- a/packages/middleware/fastify/test/originValidation.test.ts +++ b/packages/middleware/fastify/test/originValidation.test.ts @@ -82,6 +82,20 @@ describe('@modelcontextprotocol/fastify origin validation', () => { expect(good.statusCode).toBe(200); }); + test('admits a browser-extension origin once its scheme is listed in allowedOrigins', async () => { + const headers = { host: 'localhost:3000', origin: 'chrome-extension://abcdefghijklmnopabcdefghijklmnop' }; + + const byDefault = createMcpFastifyApp(); + byDefault.get('/health', async () => 'ok'); + const rejected1 = await byDefault.inject({ method: 'GET', url: '/health', headers }); + expect(rejected1.statusCode).toBe(403); + + const withScheme = createMcpFastifyApp({ allowedOrigins: ['localhost', 'chrome-extension://*'] }); + withScheme.get('/health', async () => 'ok'); + const admitted2 = await withScheme.inject({ method: 'GET', url: '/health', headers }); + expect(admitted2.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'); diff --git a/packages/middleware/hono/package.json b/packages/middleware/hono/package.json index 188f210e4d..89d52003b2 100644 --- a/packages/middleware/hono/package.json +++ b/packages/middleware/hono/package.json @@ -3,7 +3,7 @@ "private": false, "version": "2.0.1", "description": "Hono adapters for the Model Context Protocol TypeScript server SDK - Hono middleware", - "license": "MIT", + "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", diff --git a/packages/middleware/hono/src/hono.ts b/packages/middleware/hono/src/hono.ts index a72cd93fa0..7cc03a3a0a 100644 --- a/packages/middleware/hono/src/hono.ts +++ b/packages/middleware/hono/src/hono.ts @@ -34,6 +34,11 @@ export interface CreateMcpHonoAppOptions { * 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`. + * + * A client that runs as a browser extension sends its extension ID as the origin + * hostname: list that ID to admit one extension. A lowercase entry of the form + * `://*` (e.g. `'moz-extension://*'`) admits every extension of that browser. + * `http://*` and `https://*` are not honoured. */ allowedOrigins?: string[]; diff --git a/packages/middleware/hono/test/originValidation.test.ts b/packages/middleware/hono/test/originValidation.test.ts index eb85cec1b2..ec818898cc 100644 --- a/packages/middleware/hono/test/originValidation.test.ts +++ b/packages/middleware/hono/test/originValidation.test.ts @@ -59,6 +59,20 @@ describe('@modelcontextprotocol/hono origin validation', () => { expect(noOrigin.status).toBe(200); }); + test('createMcpHonoApp admits a browser-extension origin once its scheme is listed in allowedOrigins', async () => { + const headers = { Host: 'localhost:3000', Origin: 'moz-extension://0a1b2c3d-0000-4000-8000-000000000000' }; + + const byDefault = createMcpHonoApp(); + byDefault.get('/health', c => c.text('ok')); + const rejected1 = await byDefault.request('http://localhost/health', { headers }); + expect(rejected1.status).toBe(403); + + const withScheme = createMcpHonoApp({ allowedOrigins: ['localhost', 'moz-extension://*'] }); + withScheme.get('/health', c => c.text('ok')); + const admitted2 = await withScheme.request('http://localhost/health', { headers }); + expect(admitted2.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'] }); diff --git a/packages/middleware/node/package.json b/packages/middleware/node/package.json index 86807ade45..25ef2cabdb 100644 --- a/packages/middleware/node/package.json +++ b/packages/middleware/node/package.json @@ -2,7 +2,7 @@ "name": "@modelcontextprotocol/node", "version": "2.1.0", "description": "Model Context Protocol implementation for TypeScript - Node.js middleware", - "license": "MIT", + "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", diff --git a/packages/middleware/node/src/middleware/originValidation.ts b/packages/middleware/node/src/middleware/originValidation.ts index a38fc05144..a0f4299dd2 100644 --- a/packages/middleware/node/src/middleware/originValidation.ts +++ b/packages/middleware/node/src/middleware/originValidation.ts @@ -12,7 +12,7 @@ import { localhostAllowedOrigins, validateOriginHeader } from '@modelcontextprot * 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). + * @param allowedOriginHostnames - List of allowed origin hostnames (without scheme or port), or `://*` entries. * For IPv6, provide the address with brackets (e.g., `[::1]`). * * @example diff --git a/packages/server-legacy/package.json b/packages/server-legacy/package.json index f89b1a6dea..4ee45161c2 100644 --- a/packages/server-legacy/package.json +++ b/packages/server-legacy/package.json @@ -4,7 +4,7 @@ "version": "2.2.0", "description": "Frozen v1 SSE transport and OAuth Authorization Server helpers for the Model Context Protocol TypeScript SDK. Deprecated; use StreamableHTTP and a dedicated OAuth server in production.", "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", - "license": "MIT", + "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", diff --git a/packages/server/package.json b/packages/server/package.json index a7f0d2d68f..84f2d331a7 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -2,7 +2,7 @@ "name": "@modelcontextprotocol/server", "version": "2.2.0", "description": "Model Context Protocol implementation for TypeScript - Server package", - "license": "MIT", + "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", diff --git a/packages/server/src/server/listenRouter.ts b/packages/server/src/server/listenRouter.ts index 32ae8f26df..e58807d5c1 100644 --- a/packages/server/src/server/listenRouter.ts +++ b/packages/server/src/server/listenRouter.ts @@ -105,7 +105,7 @@ export interface ListenRouter { * against what the serving instance advertises (honoring a filter without * capabilities would fail open and deliver unadvertised types). * `serverInfo` is the serving instance's identity, stamped onto the - * graceful-close result's `_meta` (the spec's `SubscriptionsListenResultMeta` + * graceful-close result's `_meta` (the spec's `SubscriptionsListenResultMetaObject` * extends `ResultMetaObject`, so the serverInfo SHOULD applies there too). */ serve(message: JSONRPCRequest, signal: AbortSignal | undefined, capabilities: ServerCapabilities, serverInfo: Implementation): Response; @@ -311,7 +311,7 @@ export class StdioListenRouter { private _serverCapabilities: ServerCapabilities | undefined; /** * The serving instance's identity, stamped onto the graceful-close - * results' `_meta` (the spec's `SubscriptionsListenResultMeta` extends + * results' `_meta` (the spec's `SubscriptionsListenResultMetaObject` extends * `ResultMetaObject`). Handed over together with the capabilities. */ private _serverInfo: Implementation | undefined; diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 01c63705d4..2aeb657e7e 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -1082,6 +1082,19 @@ export class McpServer { * ); * ``` */ + registerPrompt( + name: string, + config: { + title?: string; + description?: string; + argsSchema?: undefined; + icons?: Icon[]; + /** Determines whether this prompt retrieval needs an OAuth scope challenge. */ + scopeChallenge?: ScopeChallengeHandler; + _meta?: Record; + }, + cb: PromptCallback + ): RegisteredPrompt; registerPrompt( name: string, config: { @@ -1118,7 +1131,7 @@ export class McpServer { scopeChallenge?: ScopeChallengeHandler; _meta?: Record; }, - cb: PromptCallback | LegacyPromptCallback + cb: PromptCallback | PromptCallback | LegacyPromptCallback ): RegisteredPrompt { if (this._registeredPrompts[name]) { throw new Error(`Prompt ${name} is already registered`); diff --git a/packages/server/src/server/middleware/originValidation.ts b/packages/server/src/server/middleware/originValidation.ts index 9b8b68c11e..83147cea3f 100644 --- a/packages/server/src/server/middleware/originValidation.ts +++ b/packages/server/src/server/middleware/originValidation.ts @@ -32,6 +32,11 @@ export type OriginValidationResult = * 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]`). + * - A browser extension's origin carries its extension ID as the hostname; list the + * ID to allow one extension. An allowlist item of the form `://*` + * (lowercase, e.g. `moz-extension://*`) allows every origin of that scheme, which + * Firefox needs because its extension IDs differ on every install. + * `http://*` and `https://*` are not honoured: list websites by hostname. * - 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). */ @@ -41,8 +46,11 @@ export function validateOriginHeader(originHeader: string | null | undefined, al } let hostname: string; + let scheme: string; try { - hostname = new URL(originHeader).hostname; + const url = new URL(originHeader); + hostname = url.hostname; + scheme = url.protocol; } catch { return { ok: false, errorCode: 'invalid_origin_header', message: `Invalid Origin header: ${originHeader}`, originHeader }; } @@ -52,7 +60,8 @@ export function validateOriginHeader(originHeader: string | null | undefined, al return { ok: false, errorCode: 'invalid_origin_header', message: `Invalid Origin header: ${originHeader}`, originHeader }; } - if (!allowedOriginHostnames.includes(hostname)) { + const schemeAllowed = scheme !== 'http:' && scheme !== 'https:' && allowedOriginHostnames.includes(`${scheme}//*`); + if (!schemeAllowed && !allowedOriginHostnames.includes(hostname)) { return { ok: false, errorCode: 'invalid_origin', message: `Invalid Origin: ${hostname}`, originHeader, hostname }; } diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 5de0d8919c..2eee0b9ff4 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -244,7 +244,7 @@ export function installModernOnlyHandlers(server: Server, servedModernVersions: * Package-internal: the instance's implementation identity, for the serving * entries to stamp onto entry-built results (the `subscriptions/listen` * graceful-close result — built outside the encode seam, but the spec's - * `SubscriptionsListenResultMeta` extends `ResultMetaObject`, so it carries + * `SubscriptionsListenResultMetaObject` extends `ResultMetaObject`, so it carries * the serverInfo SHOULD like every other result). Not public API. */ export function serverIdentityOf(server: Server): Implementation { diff --git a/packages/server/test/server/createMcpHandlerListen.test.ts b/packages/server/test/server/createMcpHandlerListen.test.ts index 392887f0b3..579e61d854 100644 --- a/packages/server/test/server/createMcpHandlerListen.test.ts +++ b/packages/server/test/server/createMcpHandlerListen.test.ts @@ -256,7 +256,7 @@ describe('createMcpHandler — subscriptions/listen', () => { _meta: { 'io.modelcontextprotocol/subscriptionId': 1, // #3002: the close result carries the serving instance's - // identity like every other result (SubscriptionsListenResultMeta + // identity like every other result (SubscriptionsListenResultMetaObject // extends ResultMetaObject). 'io.modelcontextprotocol/serverInfo': { name: 'listen-test-server', version: '1.0.0' } } diff --git a/packages/server/test/server/originValidation.test.ts b/packages/server/test/server/originValidation.test.ts index e76a2f5c3f..b0e194d70b 100644 --- a/packages/server/test/server/originValidation.test.ts +++ b/packages/server/test/server/originValidation.test.ts @@ -33,6 +33,34 @@ describe('validateOriginHeader', () => { expect(validateOriginHeader('http://localhost.evil.example.com', localhostAllowedOrigins()).ok).toBe(false); }); + it('allows one browser extension when its ID is listed', () => { + const allowed = ['abcdefghijklmnopabcdefghijklmnop']; + expect(validateOriginHeader('chrome-extension://abcdefghijklmnopabcdefghijklmnop', allowed).ok).toBe(true); + expect(validateOriginHeader('chrome-extension://ponmlkjihgfedcbaponmlkjihgfedcba', allowed).ok).toBe(false); + }); + + it('allows every origin of a scheme listed as `://*`', () => { + const allowed = ['myapp.local', 'chrome-extension://*']; + expect(validateOriginHeader('chrome-extension://abcdefghijklmnopabcdefghijklmnop', allowed).ok).toBe(true); + expect(validateOriginHeader('moz-extension://0a1b2c3d-0000-4000-8000-000000000000', allowed).ok).toBe(false); + expect(validateOriginHeader('https://evil.example.com', allowed).ok).toBe(false); + expect(validateOriginHeader('https://chrome-extension', allowed).ok).toBe(false); + expect(validateOriginHeader('null', allowed).ok).toBe(false); + }); + + it('does not honour `http://*` or `https://*`: websites are listed by hostname', () => { + const allowed = ['http://*', 'https://*', 'myapp.local']; + expect(validateOriginHeader('https://evil.example.com', allowed).ok).toBe(false); + expect(validateOriginHeader('http://evil.example.com', allowed).ok).toBe(false); + expect(validateOriginHeader('https://myapp.local', allowed).ok).toBe(true); + }); + + it('localhostAllowedOrigins rejects browser-extension origins unless their scheme is added', () => { + const extension = 'moz-extension://0a1b2c3d-0000-4000-8000-000000000000'; + expect(validateOriginHeader(extension, localhostAllowedOrigins()).ok).toBe(false); + expect(validateOriginHeader(extension, [...localhostAllowedOrigins(), 'moz-extension://*']).ok).toBe(true); + }); + 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()); diff --git a/packages/server/test/server/registerPromptNoArgs.test.ts b/packages/server/test/server/registerPromptNoArgs.test.ts new file mode 100644 index 0000000000..ac012f44b1 --- /dev/null +++ b/packages/server/test/server/registerPromptNoArgs.test.ts @@ -0,0 +1,28 @@ +// Pins the types of registerPrompt() without argsSchema: the callback's one parameter is the server context. +import type { ServerContext } from '@modelcontextprotocol/core-internal'; +import { describe, expect, expectTypeOf, test } from 'vitest'; + +import { McpServer } from '../../src/server/mcp'; + +describe('registerPrompt without argsSchema', () => { + test('types the callback parameter as the server context', () => { + const server = new McpServer({ name: 'test server', version: '1.0' }); + + server.registerPrompt('ctx-only', {}, async ctx => { + expectTypeOf(ctx).toEqualTypeOf(); + return { messages: [{ role: 'assistant' as const, content: { type: 'text' as const, text: String(ctx.mcpReq.id) } }] }; + }); + + expect(server.server).toBeDefined(); + }); + + test('still accepts a callback that ignores the context', () => { + const server = new McpServer({ name: 'test server', version: '1.0' }); + + server.registerPrompt('no-args', { description: 'takes nothing' }, async () => ({ + messages: [{ role: 'assistant' as const, content: { type: 'text' as const, text: 'ok' } }] + })); + + expect(server.server).toBeDefined(); + }); +}); diff --git a/packages/server/test/server/serveStdioListen.test.ts b/packages/server/test/server/serveStdioListen.test.ts index 44deb64396..c084f87169 100644 --- a/packages/server/test/server/serveStdioListen.test.ts +++ b/packages/server/test/server/serveStdioListen.test.ts @@ -122,7 +122,7 @@ describe('serveStdio — subscriptions/listen', () => { const results = inbound.filter(m => 'result' in m) as { id: unknown; result: unknown }[]; expect(results.map(m => m.id)).toEqual(['s1', 's2']); // #3002: each close result carries the serving instance's identity - // (SubscriptionsListenResultMeta extends ResultMetaObject). + // (SubscriptionsListenResultMetaObject extends ResultMetaObject). const serverInfo = { name: 's', version: '1' }; expect(results.map(m => m.result)).toEqual([ { resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: 's1', 'io.modelcontextprotocol/serverInfo': serverInfo } }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c663ad7086..cb32c9289c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1972,6 +1972,9 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 cors: specifier: catalog:runtimeServerOnly version: 2.8.6 @@ -2083,6 +2086,9 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 vitest: 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)) @@ -2122,6 +2128,9 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 '@valibot/to-json-schema': specifier: catalog:devTools version: 1.6.0(valibot@1.3.1(typescript@5.9.3)) diff --git a/scripts/fetch-schema-twins.ts b/scripts/fetch-schema-twins.ts index d693f45905..b13ffecdce 100644 --- a/scripts/fetch-schema-twins.ts +++ b/scripts/fetch-schema-twins.ts @@ -7,18 +7,21 @@ * 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. + * is locked to the manifest's source commit, sha256, and byte count. + * + * Each revision records its own source commit. Released revision directories + * can continue to change upstream, so freezing one revision must not move a + * different revision's oracle. * * Refresh ATOMICALLY with the matching spec.types anchor (see * packages/core-internal/src/types/README.md lifecycle rule 4). * * Usage: - * pnpm fetch:schema-twins [sha] # default: the manifest's current source commit + * pnpm fetch:schema-twins [sha] # default: each manifest entry'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. + * Passing a SHA preserves the previous CLI behavior and repins all twins to + * that repository snapshot. With no SHA, each twin regenerates from its own + * recorded source commit. */ import { createHash } from 'node:crypto'; @@ -33,10 +36,17 @@ const SPEC_REPO = 'modelcontextprotocol/modelcontextprotocol'; const TWINS_DIR = join(PROJECT_ROOT, 'packages', 'core-internal', 'test', 'corpus', 'schema-twins'); const MANIFEST_PATH = join(TWINS_DIR, 'manifest.json'); +interface TwinEntry { + sourceCommit: string; + sha256: string; + bytes: number; + upstreamPath: string; +} + interface TwinManifest { comment: string; - source: { repository: string; commit: string }; - files: Record; + source: { repository: string }; + files: Record; } async function fetchRawBytes(sha: string, upstreamPath: string): Promise { @@ -50,19 +60,25 @@ async function fetchRawBytes(sha: string, upstreamPath: string): Promise async function main(): Promise { const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as TwinManifest; - const sha = process.argv[2] ?? manifest.source.commit; + if (manifest.source.repository !== SPEC_REPO) { + throw new Error(`Unexpected schema-twin source repository: ${manifest.source.repository}`); + } + + const providedSHA = process.argv[2]; for (const [revision, entry] of Object.entries(manifest.files)) { + const sha = providedSHA ?? entry.sourceCommit; 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.sourceCommit = sha; 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}`); } diff --git a/scripts/fetch-spec-examples.ts b/scripts/fetch-spec-examples.ts index bc279551d9..005627b4c8 100644 --- a/scripts/fetch-spec-examples.ts +++ b/scripts/fetch-spec-examples.ts @@ -1,9 +1,9 @@ /** - * Vendors the draft-revision (2026-07-28) example corpus from the spec - * repository into `packages/core-internal/test/corpus/fixtures/2026-07-28/`. + * Vendors the released 2026-07-28 example corpus from the spec repository + * into `packages/core-internal/test/corpus/fixtures/2026-07-28/`. * - * The spec repository ships canonical example instances for the draft schema - * (`schema/draft/examples//*.json`). The corpus harness + * The spec repository ships canonical example instances for the released schema + * (`schema/2026-07-28/examples//*.json`). The corpus harness * (`packages/core-internal/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. @@ -13,7 +13,7 @@ * * Usage: * pnpm fetch:spec-examples --spec-dir - * pnpm fetch:spec-examples [sha] # fetch from GitHub (default: latest main) + * pnpm fetch:spec-examples [sha] # fetch from GitHub (default: released pin) * * With `--spec-dir`, examples are read from a local checkout of * modelcontextprotocol/modelcontextprotocol (provenance is the checkout's @@ -30,10 +30,10 @@ 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-internal', 'test', 'corpus', 'fixtures', DRAFT_REVISION); +const REVISION = '2026-07-28'; +const RELEASED_REVISION_PIN = '271ecc9accafdd9b83a3c869fa67c22953b2af80'; +const EXAMPLES_PATH = `schema/${REVISION}/examples`; +const OUTPUT_DIR = join(PROJECT_ROOT, 'packages', 'core-internal', 'test', 'corpus', 'fixtures', REVISION); interface ExampleFile { /** `/.json` relative to the examples root. */ @@ -41,15 +41,6 @@ interface ExampleFile { 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); @@ -115,7 +106,7 @@ function writeCorpus(files: ExampleFile[], sha: string): void { } const manifest = { - revision: DRAFT_REVISION, + revision: 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, @@ -139,7 +130,7 @@ async function main(): Promise { return; } - const sha = args[0] ?? (await fetchLatestSHA()); + const sha = args[0] ?? RELEASED_REVISION_PIN; const files = await fetchExamplesFromGitHub(sha); writeCorpus(files, sha); } diff --git a/scripts/fetch-spec-types.ts b/scripts/fetch-spec-types.ts index c4fc9818ec..06b426f23d 100644 --- a/scripts/fetch-spec-types.ts +++ b/scripts/fetch-spec-types.ts @@ -10,38 +10,33 @@ const PROJECT_ROOT = join(__dirname, '..'); /** * The protocol revisions the SDK keeps reference types for: * - `2025-11-25`: the frozen, released schema. - * - `2026-07-28`: the upcoming protocol revision. + * - `2026-07-28`: the frozen, released schema. * * Each is written to `packages/core-internal/src/types/spec.types..ts`. */ const SUPPORTED_VERSIONS = ['2025-11-25', '2026-07-28'] as const; type SpecVersion = (typeof SUPPORTED_VERSIONS)[number]; -/** - * Upstream schema directory per revision. Until the 2026-07-28 revision is - * published, its schema lives in the spec repository's draft directory; this - * mapping drops once `schema/2026-07-28/` exists upstream. - */ +/** Upstream schema directory for each supported protocol revision. */ const UPSTREAM_SCHEMA_DIRS: Record = { '2025-11-25': '2025-11-25', - '2026-07-28': 'draft' + '2026-07-28': '2026-07-28' }; /** * 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`. + * can only change through a deliberate, reviewed repin. * - * Draft-tracking revisions have no entry and float to the latest upstream - * commit via the nightly workflow's refresh PRs. + * Draft-tracking revisions have no entry and may float to the latest upstream + * commit only while the SDK has an explicit consumer for that unreleased revision. * * See `packages/core-internal/src/types/README.md` for the full lifecycle policy. */ const RELEASED_REVISION_PINS: Partial> = { - '2025-11-25': '0168c57fc74aba6e6dcf8f0b7191db3caaa5ad65' + '2025-11-25': '0168c57fc74aba6e6dcf8f0b7191db3caaa5ad65', + '2026-07-28': '271ecc9accafdd9b83a3c869fa67c22953b2af80' }; interface GitHubCommit { @@ -127,7 +122,7 @@ function isSupportedVersion(value: string): value is SpecVersion { async function main() { try { // Usage: fetch-spec-types.ts [version] [sha] - // With no version, all supported versions are fetched at their latest upstream SHA. + // With no version, all supported versions are regenerated from their configured authority. const providedVersion = process.argv[2]; const providedSHA = process.argv[3]; diff --git a/test/conformance/package.json b/test/conformance/package.json index f51e84d9ea..293180e6e3 100644 --- a/test/conformance/package.json +++ b/test/conformance/package.json @@ -22,6 +22,7 @@ "mcp" ], "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", "check": "npm run typecheck && npm run lint", @@ -51,6 +52,7 @@ "cors": "catalog:runtimeServerOnly", "express": "catalog:runtimeServerOnly", "tsx": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", "zod": "catalog:runtimeShared" } } diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index 25926ba2a7..6ede22b2bd 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -200,7 +200,10 @@ function createMcpServer() { inputSchema: fromJsonSchema<{ region?: string; level?: number }>({ type: 'object', properties: { - region: { type: 'string', description: 'mirrored into Mcp-Param-Region', 'x-mcp-header': 'Region' }, + region: { type: 'string', description: 'mirrored into Mcp-Param-Region', 'x-mcp-header': 'Region' } as Record< + string, + unknown + >, level: { type: 'integer', description: 'non-mirrored argument' } } }) diff --git a/test/conformance/tsconfig.json b/test/conformance/tsconfig.json index b424eb35ec..a7617960f8 100644 --- a/test/conformance/tsconfig.json +++ b/test/conformance/tsconfig.json @@ -13,7 +13,9 @@ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], + "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], diff --git a/test/helpers/package.json b/test/helpers/package.json index c359fbe6c2..60e0c6cae6 100644 --- a/test/helpers/package.json +++ b/test/helpers/package.json @@ -22,6 +22,7 @@ "mcp" ], "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", "check": "npm run typecheck && npm run lint" @@ -32,6 +33,7 @@ "vitest": "catalog:devTools", "@modelcontextprotocol/tsconfig": "workspace:^", "@modelcontextprotocol/vitest-config": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^" + "@modelcontextprotocol/eslint-config": "workspace:^", + "@typescript/native-preview": "catalog:devTools" } } diff --git a/test/integration/package.json b/test/integration/package.json index ed75fb1b0c..df35ac13a5 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -22,6 +22,7 @@ "mcp" ], "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", "lint": "eslint test/ && prettier --ignore-path ../../.prettierignore --check .", "lint:fix": "eslint test/ --fix && prettier --ignore-path ../../.prettierignore --write .", "check": "npm run typecheck && npm run lint", @@ -42,6 +43,7 @@ "@modelcontextprotocol/tsconfig": "workspace:^", "@modelcontextprotocol/vitest-config": "workspace:^", "@valibot/to-json-schema": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", "arktype": "catalog:devTools", "supertest": "catalog:devTools", "valibot": "catalog:devTools", diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index 92140b531f..bc5d19b8cf 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -73,7 +73,8 @@ test('should initialize with matching protocol version', async () => { * Test: Initialize with Supported Older Protocol Version */ test('should initialize with supported older protocol version', async () => { - const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + // Index 1 is always present; `noUncheckedIndexedAccess` cannot see that. + const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]!; const clientTransport: Transport = { start: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), @@ -277,7 +278,8 @@ test('should reject unsupported protocol version', async () => { * Test: Connect New Client to Old Supported Server Version */ test('should connect new client to old, supported server version', async () => { - const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + // Index 1 is always present; `noUncheckedIndexedAccess` cannot see that. + const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]!; const server = new Server( { name: 'test server', @@ -725,7 +727,7 @@ test('should only allow setRequestHandler for declared capabilities', () => { // This should throw because roots listing is not a declared capability expect(() => { - client.setRequestHandler('roots/list', () => ({})); + client.setRequestHandler('roots/list', () => ({ roots: [] })); }).toThrow('Client does not support roots capability'); }); diff --git a/test/integration/test/server.test.ts b/test/integration/test/server.test.ts index 51cde29161..0d5faabbe1 100644 --- a/test/integration/test/server.test.ts +++ b/test/integration/test/server.test.ts @@ -352,8 +352,9 @@ test('should respect client elicitation capabilities', async () => { client.setRequestHandler('elicitation/create', params => ({ action: 'accept', + // Omit `username`: content values cannot be `undefined`. content: { - username: params.params.message.includes('username') ? 'test-user' : undefined, + ...(params.params.message.includes('username') ? { username: 'test-user' } : {}), confirmed: true } })); @@ -436,8 +437,9 @@ test('should use elicitInput with mode: "form" by default for backwards compatib client.setRequestHandler('elicitation/create', params => ({ action: 'accept', + // Omit `username`: content values cannot be `undefined`. content: { - username: params.params.message.includes('username') ? 'test-user' : undefined, + ...(params.params.message.includes('username') ? { username: 'test-user' } : {}), confirmed: true } })); diff --git a/test/integration/test/server/cloudflareWorkers.test.ts b/test/integration/test/server/cloudflareWorkers.test.ts index 02add64201..45d0117510 100644 --- a/test/integration/test/server/cloudflareWorkers.test.ts +++ b/test/integration/test/server/cloudflareWorkers.test.ts @@ -36,7 +36,7 @@ const SERVER_VERSION_NONCE = randomUUID(); */ const WRANGLER_BIN = (() => { const pkgPath = createRequire(import.meta.url).resolve('wrangler/package.json'); - const bin = (JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { bin: Record }).bin.wrangler; + const bin = (JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { bin: Record }).bin.wrangler!; return path.resolve(path.dirname(pkgPath), bin); })(); diff --git a/test/integration/test/server/declaredCapabilities.test.ts b/test/integration/test/server/declaredCapabilities.test.ts index 60e214d129..dcab27f4a6 100644 --- a/test/integration/test/server/declaredCapabilities.test.ts +++ b/test/integration/test/server/declaredCapabilities.test.ts @@ -140,12 +140,12 @@ describe('deterministic tools/list ordering (draft spec)', () => { const client = await connect(mcpServer); // Disable a tool in the middle: relative order of the remaining tools is unchanged. - registered[2].disable(); + registered[2]!.disable(); const whileDisabled = await client.listTools(); expect(whileDisabled.tools.map(t => t.name)).toEqual(['zeta', 'alpha', 'omega', 'beta']); // Re-enable it: the original insertion order is restored, not appended at the end. - registered[2].enable(); + registered[2]!.enable(); const afterReenable = await client.listTools(); expect(afterReenable.tools.map(t => t.name)).toEqual(names); }); diff --git a/test/integration/test/server/dualEraStdio.test.ts b/test/integration/test/server/dualEraStdio.test.ts index 9cef3b8f7b..04dd468d3e 100644 --- a/test/integration/test/server/dualEraStdio.test.ts +++ b/test/integration/test/server/dualEraStdio.test.ts @@ -53,9 +53,9 @@ function spawnFixtureTransport(): StdioClientTransport { function recordInbound(transport: StdioClientTransport): JSONRPCMessage[] { const inbound: JSONRPCMessage[] = []; const original = transport.onmessage; - transport.onmessage = (message, extra) => { + transport.onmessage = message => { inbound.push(message); - original?.(message, extra); + original?.(message); }; return inbound; } @@ -64,9 +64,9 @@ function recordInbound(transport: StdioClientTransport): JSONRPCMessage[] { function recordOutbound(transport: StdioClientTransport): JSONRPCMessage[] { const outbound: JSONRPCMessage[] = []; const originalSend = transport.send.bind(transport); - transport.send = async (message, options) => { + transport.send = async message => { outbound.push(message); - return originalSend(message, options); + return originalSend(message); }; return outbound; } diff --git a/test/integration/test/server/elicitation.test.ts b/test/integration/test/server/elicitation.test.ts index 13fb77e944..c8819554f7 100644 --- a/test/integration/test/server/elicitation.test.ts +++ b/test/integration/test/server/elicitation.test.ts @@ -8,7 +8,7 @@ */ import { Client } from '@modelcontextprotocol/client'; -import type { ElicitRequestFormParams } from '@modelcontextprotocol/core-internal'; +import type { ElicitRequestFormParams, ElicitResult } from '@modelcontextprotocol/core-internal'; import { InMemoryTransport } from '@modelcontextprotocol/core-internal'; import { AjvJsonSchemaValidator } from '@modelcontextprotocol/core-internal/validators/ajv'; import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core-internal/validators/cfWorker'; @@ -338,7 +338,8 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo test(`${validatorName}: should handle multiple sequential elicitation requests`, async () => { let requestCount = 0; - client.setRequestHandler('elicitation/create', request => { + // Annotated so each branch widens to the shared result type. + client.setRequestHandler('elicitation/create', (request): ElicitResult => { requestCount++; if (request.params.message.includes('name')) { return { action: 'accept', content: { name: 'Alice' } }; @@ -996,12 +997,12 @@ describe('declared-dialect requestedSchema (default validator)', () => { const requestedSchema = { $schema: 'http://json-schema.org/draft-07/schema#', - type: 'object', - properties: { name: { type: 'string', minLength: 1 } }, + type: 'object' as const, + properties: { name: { type: 'string' as const, minLength: 1 } }, required: ['name'] - } as const; + }; - let content: Record = { name: 'John' }; + let content: { [key: string]: string | number | boolean | string[] } = { name: 'John' }; client.setRequestHandler('elicitation/create', () => ({ action: 'accept', content })); await expect(server.elicitInput({ mode: 'form', message: 'name?', requestedSchema })).resolves.toMatchObject({ diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 4b9a3865f0..8bd40a8459 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -1,5 +1,5 @@ import { Client } from '@modelcontextprotocol/client'; -import type { Notification, TextContent } from '@modelcontextprotocol/core-internal'; +import type { Notification, ServerContext, TextContent } from '@modelcontextprotocol/core-internal'; import { getDisplayName, InMemoryTransport, @@ -411,16 +411,14 @@ describe('Zod v4', () => { expect(template.listCallback).toBe(list); const abortController = new AbortController(); + // A partial context is enough for what the callback reads. const result = await template.listCallback?.({ - signal: abortController.signal, - requestId: 'not-implemented', - sendRequest: () => { - throw new Error('Not implemented'); - }, - sendNotification: () => { - throw new Error('Not implemented'); + mcpReq: { + id: 'not-implemented', + method: 'resources/list', + signal: abortController.signal } - }); + } as unknown as ServerContext); expect(result?.resources).toHaveLength(1); expect(list).toHaveBeenCalled(); }); @@ -598,11 +596,12 @@ describe('Zod v4', () => { name: z.string(), value: z.number() }), - callback: async ({ name, value }) => ({ + // `update()` types `args` as `unknown`; narrow it here. + callback: async args => ({ content: [ { type: 'text', - text: `Updated: ${name}, ${value}` + text: `Updated: ${(args as { name: string }).name}, ${(args as { value: number }).value}` } ] }) @@ -850,7 +849,7 @@ describe('Zod v4', () => { version: '1.0' }); - mcpServer.registerResource('test://resource', 'Test Resource', async () => ({ + mcpServer.registerResource('test-resource', 'test://resource', {}, async () => ({ contents: [{ uri: 'test://resource', text: 'Test' }] })); @@ -871,7 +870,7 @@ describe('Zod v4', () => { version: '1.0' }); - mcpServer.registerPrompt('test-prompt', async () => ({ + mcpServer.registerPrompt('test-prompt', {}, async () => ({ messages: [{ role: 'assistant', content: { type: 'text', text: 'Test' } }] })); diff --git a/test/integration/test/standardSchema.test.ts b/test/integration/test/standardSchema.test.ts index b9ab0284f3..cc82977a9e 100644 --- a/test/integration/test/standardSchema.test.ts +++ b/test/integration/test/standardSchema.test.ts @@ -57,8 +57,8 @@ describe('Standard Schema Support', () => { const result = await client.request({ method: 'tools/list' }); expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('greet'); - expect(result.tools[0].inputSchema).toMatchObject({ + expect(result.tools[0]?.name).toBe('greet'); + expect(result.tools[0]?.inputSchema).toMatchObject({ $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object', properties: { @@ -67,7 +67,7 @@ describe('Standard Schema Support', () => { } }); // Check required array contains both fields (order may vary by library) - expect(result.tools[0].inputSchema.required).toEqual(expect.arrayContaining(['name', 'age'])); + expect(result.tools[0]?.inputSchema.required).toEqual(expect.arrayContaining(['name', 'age'])); }); test('should register tool with ArkType input and output schemas', async () => { @@ -91,7 +91,7 @@ describe('Standard Schema Support', () => { const result = await client.request({ method: 'tools/list' }); - expect(result.tools[0].outputSchema).toMatchObject({ + expect(result.tools[0]?.outputSchema).toMatchObject({ $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object', properties: { @@ -99,7 +99,7 @@ describe('Standard Schema Support', () => { operation: { type: 'string' } } }); - expect(result.tools[0].outputSchema!.required).toEqual(expect.arrayContaining(['result', 'operation'])); + expect(result.tools[0]?.outputSchema!.required).toEqual(expect.arrayContaining(['result', 'operation'])); }); }); @@ -212,8 +212,8 @@ describe('Standard Schema Support', () => { const result = await client.request({ method: 'tools/list' }); expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('greet'); - expect(result.tools[0].inputSchema).toMatchObject({ + expect(result.tools[0]?.name).toBe('greet'); + expect(result.tools[0]?.inputSchema).toMatchObject({ type: 'object', properties: { name: { type: 'string' }, @@ -239,7 +239,7 @@ describe('Standard Schema Support', () => { const result = await client.request({ method: 'tools/list' }); - expect(result.tools[0].inputSchema.properties).toMatchObject({ + expect(result.tools[0]?.inputSchema.properties).toMatchObject({ city: { type: 'string', description: 'The city name' }, country: { type: 'string', description: 'The country code' } }); @@ -396,7 +396,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); const listed = await client.request({ method: 'tools/list' }); - expect(listed.tools[0].inputSchema).toMatchObject({ + expect(listed.tools[0]?.inputSchema).toMatchObject({ type: 'object', properties: { name: { type: 'string' } }, required: ['name']