From 468e952a07f4fdb7d8ce309409c71b8a8fbc4567 Mon Sep 17 00:00:00 2001 From: squarepots <46488165+squarepots@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:41:18 +0800 Subject: [PATCH 01/24] fix(spec): freeze 2026-07-28 release references --- .github/workflows/update-spec-types.yml | 95 ------------------- packages/core-internal/src/types/README.md | 10 +- .../src/types/spec.types.2026-07-28.ts | 29 ++++-- .../src/wire/rev2026-07-28/buildSchemas.ts | 6 +- .../src/wire/rev2026-07-28/schemas.ts | 1 + .../listen-closed-response.json | 10 ++ .../corpus/fixtures/2026-07-28/manifest.json | 11 ++- .../schema-twins/2026-07-28.schema.json | 25 ++++- .../test/corpus/schema-twins/manifest.json | 15 +-- .../test/corpus/specCorpus.test.ts | 16 ++-- .../test/spec.types.2026-07-28.test.ts | 14 ++- .../test/wire/schemaTwinConformance.test.ts | 5 +- packages/server/src/server/listenRouter.ts | 4 +- packages/server/src/server/server.ts | 2 +- .../server/createMcpHandlerListen.test.ts | 2 +- .../test/server/serveStdioListen.test.ts | 2 +- scripts/fetch-schema-twins.ts | 47 ++++++--- scripts/fetch-spec-examples.ts | 31 +++--- scripts/fetch-spec-types.ts | 23 ++--- 19 files changed, 160 insertions(+), 188 deletions(-) delete mode 100644 .github/workflows/update-spec-types.yml create mode 100644 packages/core-internal/test/corpus/fixtures/2026-07-28/SubscriptionsListenResultResponse/listen-closed-response.json 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/packages/core-internal/src/types/README.md b/packages/core-internal/src/types/README.md index fda450e8e9..374032f85d 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 + **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 revision'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 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 + `schema//schema.json` from that revision's pinned source commit over the twin and update `manifest.json` in the same commit; the spec example corpus manifest (`test/corpus/fixtures//manifest.json`) records its own source commit and follows the same atomicity rule when the examples are re-vendored. The conformance lock failing after an anchor-only refresh is the desired loud signal of a missed twin update. diff --git a/packages/core-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/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..10be62d587 100644 --- a/packages/core-internal/test/corpus/schema-twins/manifest.json +++ b/packages/core-internal/test/corpus/schema-twins/manifest.json @@ -1,19 +1,20 @@ { - "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 per-revision sourceCommit/sha256/bytes by schemaTwinConformance. Refresh via `pnpm fetch:schema-twins [revision] [sha]` (see packages/core-internal/src/types/README.md lifecycle rule 4).", "source": { - "repository": "modelcontextprotocol/modelcontextprotocol", - "commit": "71e306956a4959c9655e5036be215d41986596e6" + "repository": "modelcontextprotocol/modelcontextprotocol" }, "files": { "2026-07-28": { - "sha256": "9281c4890630e2d1e61792fa23b4084c4ea360cd58519610cd050545ab7b8708", - "bytes": 180695, - "upstreamPath": "schema/draft/schema.json" + "sha256": "ef70b61f99b6d2e5e3b46863822eab08dff6a45bedc7a08914e0e5b133f40203", + "bytes": 181474, + "upstreamPath": "schema/2026-07-28/schema.json", + "sourceCommit": "271ecc9accafdd9b83a3c869fa67c22953b2af80" }, "2025-11-25": { "sha256": "7b2d96fd95efd2216aa953606b83f5a740ddeaa5ebd3a5d27b45a8296545a118", "bytes": 174326, - "upstreamPath": "schema/2025-11-25/schema.json" + "upstreamPath": "schema/2025-11-25/schema.json", + "sourceCommit": "0168c57fc74aba6e6dcf8f0b7191db3caaa5ad65" } } } 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..bf6ed2613f 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,14 @@ 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 +882,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/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/server/src/server/listenRouter.ts b/packages/server/src/server/listenRouter.ts index 40c4a38cf2..40854df043 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; @@ -305,7 +305,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/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 fe17aa7536..cc0acc6173 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/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/scripts/fetch-schema-twins.ts b/scripts/fetch-schema-twins.ts index d693f45905..781d2bc5e6 100644 --- a/scripts/fetch-schema-twins.ts +++ b/scripts/fetch-schema-twins.ts @@ -7,18 +7,15 @@ * 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. * - * Refresh ATOMICALLY with the matching spec.types anchor (see - * packages/core-internal/src/types/README.md lifecycle rule 4). + * Each revision owns its source commit independently. Refreshing one released + * revision must not move another revision's oracle. * * Usage: - * pnpm fetch:schema-twins [sha] # default: the manifest's current source commit - * - * Sources are fetched from GitHub at the given commit, mirroring - * scripts/fetch-spec-types.ts; the manifest's provenance values (source - * commit, sha256, byte size) are recomputed from the fetched bytes. + * pnpm fetch:schema-twins + * pnpm fetch:schema-twins + * pnpm fetch:schema-twins */ import { createHash } from 'node:crypto'; @@ -33,10 +30,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 +54,34 @@ 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 [providedRevision, providedSHA, ...extraArgs] = process.argv.slice(2); + if (extraArgs.length > 0) { + throw new Error('Usage: pnpm fetch:schema-twins [revision] [sha]'); + } + if (providedRevision !== undefined && !(providedRevision in manifest.files)) { + throw new Error( + `Unsupported revision "${providedRevision}". Available revisions: ${Object.keys(manifest.files).join(', ')}` + ); + } for (const [revision, entry] of Object.entries(manifest.files)) { + if (providedRevision !== undefined && revision !== providedRevision) continue; + + 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]; From e2523025aa7993429adae4e46e285a602b61154e Mon Sep 17 00:00:00 2001 From: squarepots <46488165+squarepots@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:43:11 +0800 Subject: [PATCH 02/24] fix(spec): align 2025 anchor provenance with release pin --- packages/core-internal/src/types/spec.types.2025-11-25.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-internal/src/types/spec.types.2025-11-25.ts b/packages/core-internal/src/types/spec.types.2025-11-25.ts index 225a53c2d7..3cbbc8f6c3 100644 --- a/packages/core-internal/src/types/spec.types.2025-11-25.ts +++ b/packages/core-internal/src/types/spec.types.2025-11-25.ts @@ -3,7 +3,7 @@ * * Source: https://github.com/modelcontextprotocol/modelcontextprotocol * Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/2025-11-25/schema.ts - * Last updated from commit: 357adac47ab2654b64799f994e6db8d3df4ee19d + * Last updated from commit: 0168c57fc74aba6e6dcf8f0b7191db3caaa5ad65 * * DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates. * To update this file, run: pnpm run fetch:spec-types 2025-11-25 From 6fcda04b864c1873759fefd57d60847b65807a8c Mon Sep 17 00:00:00 2001 From: squarepots <46488165+squarepots@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:44:49 +0800 Subject: [PATCH 03/24] style(spec): format 2026 parity check --- packages/core-internal/test/spec.types.2026-07-28.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 bf6ed2613f..eb8f4d6349 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 @@ -805,7 +805,10 @@ const wireParityChecks = { sdk = spec; spec = sdk; }, - SubscriptionsListenResultMetaObject: (sdk: WSubscriptionsListenResultMeta, spec: SpecTypes.SubscriptionsListenResultMetaObject) => { + SubscriptionsListenResultMetaObject: ( + sdk: WSubscriptionsListenResultMeta, + spec: SpecTypes.SubscriptionsListenResultMetaObject + ) => { sdk = spec; spec = sdk; }, From b8d48733c287622b6474e422dd9dbb9abf9f3b11 Mon Sep 17 00:00:00 2001 From: squarepots <46488165+squarepots@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:49:21 +0800 Subject: [PATCH 04/24] style(spec): match parity check formatting --- .../core-internal/test/spec.types.2026-07-28.test.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) 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 eb8f4d6349..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 @@ -805,17 +805,11 @@ const wireParityChecks = { sdk = spec; spec = sdk; }, - SubscriptionsListenResultMetaObject: ( - sdk: WSubscriptionsListenResultMeta, - spec: SpecTypes.SubscriptionsListenResultMetaObject - ) => { + SubscriptionsListenResultMetaObject: (sdk: WSubscriptionsListenResultMeta, spec: SpecTypes.SubscriptionsListenResultMetaObject) => { sdk = spec; spec = sdk; }, - SubscriptionsListenResultResponse: ( - sdk: WSubscriptionsListenResultResponse, - spec: SpecTypes.SubscriptionsListenResultResponse - ) => { + SubscriptionsListenResultResponse: (sdk: WSubscriptionsListenResultResponse, spec: SpecTypes.SubscriptionsListenResultResponse) => { sdk = spec; spec = sdk; }, From 8abfbf5f80d2f4a02bf9488f63d01900e71ff50c Mon Sep 17 00:00:00 2001 From: squarepots <46488165+squarepots@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:45:17 +0800 Subject: [PATCH 05/24] refactor(spec): narrow release cutover scope --- packages/core-internal/src/types/README.md | 6 ++-- .../src/types/spec.types.2025-11-25.ts | 2 +- .../test/corpus/schema-twins/manifest.json | 10 +++---- scripts/fetch-schema-twins.ts | 29 +++++++++---------- 4 files changed, 22 insertions(+), 25 deletions(-) diff --git a/packages/core-internal/src/types/README.md b/packages/core-internal/src/types/README.md index 374032f85d..c0aba1bd43 100644 --- a/packages/core-internal/src/types/README.md +++ b/packages/core-internal/src/types/README.md @@ -18,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 each revision'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 copy the matching upstream - `schema//schema.json` from that revision's pinned source 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.2025-11-25.ts b/packages/core-internal/src/types/spec.types.2025-11-25.ts index 3cbbc8f6c3..225a53c2d7 100644 --- a/packages/core-internal/src/types/spec.types.2025-11-25.ts +++ b/packages/core-internal/src/types/spec.types.2025-11-25.ts @@ -3,7 +3,7 @@ * * Source: https://github.com/modelcontextprotocol/modelcontextprotocol * Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/2025-11-25/schema.ts - * Last updated from commit: 0168c57fc74aba6e6dcf8f0b7191db3caaa5ad65 + * Last updated from commit: 357adac47ab2654b64799f994e6db8d3df4ee19d * * DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates. * To update this file, run: pnpm run fetch:spec-types 2025-11-25 diff --git a/packages/core-internal/test/corpus/schema-twins/manifest.json b/packages/core-internal/test/corpus/schema-twins/manifest.json index 10be62d587..b2f097ce2c 100644 --- a/packages/core-internal/test/corpus/schema-twins/manifest.json +++ b/packages/core-internal/test/corpus/schema-twins/manifest.json @@ -1,20 +1,20 @@ { - "comment": "Vendored schema.json twins (TEST-ONLY conformance oracles; never bundled, never runtime). RAW upstream bytes - never reformat: each file is locked to its per-revision sourceCommit/sha256/bytes by schemaTwinConformance. Refresh via `pnpm fetch:schema-twins [revision] [sha]` (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" }, "files": { "2026-07-28": { + "sourceCommit": "271ecc9accafdd9b83a3c869fa67c22953b2af80", "sha256": "ef70b61f99b6d2e5e3b46863822eab08dff6a45bedc7a08914e0e5b133f40203", "bytes": 181474, - "upstreamPath": "schema/2026-07-28/schema.json", - "sourceCommit": "271ecc9accafdd9b83a3c869fa67c22953b2af80" + "upstreamPath": "schema/2026-07-28/schema.json" }, "2025-11-25": { + "sourceCommit": "71e306956a4959c9655e5036be215d41986596e6", "sha256": "7b2d96fd95efd2216aa953606b83f5a740ddeaa5ebd3a5d27b45a8296545a118", "bytes": 174326, - "upstreamPath": "schema/2025-11-25/schema.json", - "sourceCommit": "0168c57fc74aba6e6dcf8f0b7191db3caaa5ad65" + "upstreamPath": "schema/2025-11-25/schema.json" } } } diff --git a/scripts/fetch-schema-twins.ts b/scripts/fetch-schema-twins.ts index 781d2bc5e6..b13ffecdce 100644 --- a/scripts/fetch-schema-twins.ts +++ b/scripts/fetch-schema-twins.ts @@ -9,13 +9,19 @@ * formatting of any kind (the directory is .prettierignore'd) — and each file * is locked to the manifest's source commit, sha256, and byte count. * - * Each revision owns its source commit independently. Refreshing one released - * revision must not move another revision's oracle. + * 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 - * pnpm fetch:schema-twins - * pnpm fetch:schema-twins + * pnpm fetch:schema-twins [sha] # default: each manifest entry's current source commit + * + * 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'; @@ -58,23 +64,14 @@ async function main(): Promise { throw new Error(`Unexpected schema-twin source repository: ${manifest.source.repository}`); } - const [providedRevision, providedSHA, ...extraArgs] = process.argv.slice(2); - if (extraArgs.length > 0) { - throw new Error('Usage: pnpm fetch:schema-twins [revision] [sha]'); - } - if (providedRevision !== undefined && !(providedRevision in manifest.files)) { - throw new Error( - `Unsupported revision "${providedRevision}". Available revisions: ${Object.keys(manifest.files).join(', ')}` - ); - } + const providedSHA = process.argv[2]; for (const [revision, entry] of Object.entries(manifest.files)) { - if (providedRevision !== undefined && revision !== providedRevision) continue; - 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'); From 9b24218c07643ba780669e430358239dda5fbb76 Mon Sep 17 00:00:00 2001 From: squarepots <46488165+squarepots@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:23:07 +0800 Subject: [PATCH 06/24] docs(review): use revision-matched spec authority --- REVIEW.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From cfa09db614308eaf4fd575c89833da8878d4acb7 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:43:33 +0100 Subject: [PATCH 07/24] fix(client): stop listen() rejections escaping as unhandledRejection during an in-flight send (#2642) Co-authored-by: Claude Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .../listen-opening-unhandled-rejection.md | 5 + packages/client/src/client/client.ts | 33 ++++-- packages/client/test/client/listen.test.ts | 109 ++++++++++++++++++ 3 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 .changeset/listen-opening-unhandled-rejection.md diff --git a/.changeset/listen-opening-unhandled-rejection.md b/.changeset/listen-opening-unhandled-rejection.md new file mode 100644 index 0000000000..a951b4d51a --- /dev/null +++ b/.changeset/listen-opening-unhandled-rejection.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Fix `Client.listen()` rejections escaping as process-level unhandled rejections. The internal `opening` promise could reject (ack timeout, transport close, server cancel, caller abort) while `listen()` was still serially awaiting `transport.send(...)`, so no rejection handler was attached yet — the rejection surfaced as an `unhandledRejection` that caller-side handling cannot prevent, and a send that never settles (e.g. a stdio write parked on `'drain'`) left `listen()` suspended forever even though the ack timer had already fired. `listen()` now suspends on the `opening` state machine directly and routes send failures into it, so every termination path rejects the returned promise and nothing escapes. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 0b386a63e8..a6665e4e15 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -2106,18 +2106,31 @@ export class Client extends Protocol { method: 'subscriptions/listen', params: { _meta: { ...this._outboundMetaEnvelope() }, notifications: filter } }; + // The send is NOT serially awaited: `opening` must be the promise + // listen() suspends on so that a settle() firing while the send is + // still in flight (ack timeout, transport close, server cancel, a + // stdio send parked on 'drain') rejects a promise whose handler is + // already attached — otherwise the rejection escapes as a + // process-level unhandledRejection the caller cannot prevent, and a + // send that never settles leaves listen() suspended forever even + // though the ack timer already fired. + const routeSendFailure = (error: unknown): void => { + // Send failure (including a per-request abort fired before + // response headers — `streamableHttp._send` rethrows with onerror + // suppressed). `settle()` is idempotent so a locally-aborted send + // hitting this path after `close()` is a no-op. + settle({ cause: 'remote', error: error instanceof Error ? error : new Error(String(error)) }); + }; try { - await this.transport.send(jsonrpcRequest, { - requestSignal: requestAbort.signal, - onRequestStreamEnd: () => settle({ cause: 'remote', error: new Error('subscriptions/listen: stream ended') }) - }); + this.transport + .send(jsonrpcRequest, { + requestSignal: requestAbort.signal, + onRequestStreamEnd: () => settle({ cause: 'remote', error: new Error('subscriptions/listen: stream ended') }) + }) + .catch(routeSendFailure); } catch (error) { - // Synchronous OR awaited send failure (including a per-request - // abort fired before response headers — `streamableHttp._send` - // rethrows with onerror suppressed). `settle()` is idempotent so - // a locally-aborted send hitting this path after `close()` is a - // no-op. - settle({ cause: 'remote', error: error instanceof Error ? error : new Error(String(error)) }); + // A synchronous throw from send() (before it returns a promise). + routeSendFailure(error); } const honored = await opening; diff --git a/packages/client/test/client/listen.test.ts b/packages/client/test/client/listen.test.ts index 60f96be47a..acc8e33d72 100644 --- a/packages/client/test/client/listen.test.ts +++ b/packages/client/test/client/listen.test.ts @@ -953,6 +953,115 @@ describe('_resetConnectionState() clears connection-scoped debounce timers (fake }); }); +describe('Client.listen() — settle while the send is still in flight (unhandled-rejection regression)', () => { + const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + + /** + * Capture process-level unhandledRejection events for the duration of a + * test body. Every settle() path that rejects the `opening` promise while + * `transport.send()` is still pending (a stdio send parked on 'drain', a + * slow HTTP send) used to escape here — caller-side handling cannot + * prevent it because the rejection fires before `await opening` attaches. + */ + async function withUnhandledCapture(run: () => Promise): Promise { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + try { + await run(); + // Let any escaped rejection surface before uninstalling. + await sleep(30); + } finally { + process.off('unhandledRejection', onUnhandled); + } + return unhandled; + } + + /** A scripted modern connection whose `subscriptions/listen` send never settles. */ + async function parkedSendClient() { + const { clientTx, serverTx } = await scriptedModernNoAck(); + const realSend = clientTx.send.bind(clientTx); + clientTx.send = (m, opts) => { + if ((m as { method?: string }).method === 'subscriptions/listen') { + // Models a stdio send parked forever on 'drain' (stdio ignores + // TransportSendOptions.requestSignal) or any send that outlives + // the subscription. + return new Promise(() => {}); + } + return realSend(m, opts); + }; + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + return { client, clientTx, serverTx }; + } + + it('ack timeout during a parked send: listen() rejects with RequestTimeout instead of hanging; nothing escapes', async () => { + let outcome: unknown; + const unhandled = await withUnhandledCapture(async () => { + const { client } = await parkedSendClient(); + const settled = client.listen({ toolsListChanged: true }, { timeout: 50 }).then( + () => 'resolved', + e => e + ); + outcome = await Promise.race([settled, sleep(1000).then(() => 'listen() hung')]); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + }); + expect(outcome).toBeInstanceOf(SdkError); + expect((outcome as SdkError).code).toBe(SdkErrorCode.RequestTimeout); + expect(unhandled).toEqual([]); + }); + + it('transport close during a parked send: listen() rejects instead of hanging; nothing escapes', async () => { + let outcome: unknown; + const unhandled = await withUnhandledCapture(async () => { + const { client, serverTx } = await parkedSendClient(); + const settled = client.listen({ toolsListChanged: true }, { timeout: 60_000 }).then( + () => 'resolved', + e => e + ); + await flush(); + await serverTx.close(); + outcome = await Promise.race([settled, sleep(1000).then(() => 'listen() hung')]); + }); + expect(outcome).toBeInstanceOf(Error); + expect(outcome).not.toBe('listen() hung'); + expect(unhandled).toEqual([]); + }); + + it('caller-signal abort during a parked send: listen() rejects with the signal reason; nothing escapes', async () => { + let outcome: unknown; + const unhandled = await withUnhandledCapture(async () => { + const { client } = await parkedSendClient(); + const ac = new AbortController(); + const settled = client.listen({ toolsListChanged: true }, { timeout: 60_000, signal: ac.signal }).then( + () => 'resolved', + e => e + ); + await flush(); + ac.abort(new Error('caller gave up')); + outcome = await Promise.race([settled, sleep(1000).then(() => 'listen() hung')]); + }); + expect(outcome).toBeInstanceOf(Error); + expect((outcome as Error).message).toContain('caller gave up'); + expect(unhandled).toEqual([]); + }); + + it('an asynchronously-rejected send still rejects listen() with the send failure', async () => { + const unhandled = await withUnhandledCapture(async () => { + const { clientTx } = await scriptedModernNoAck(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + clientTx.send = () => Promise.reject(new Error('wire dropped')); + const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error); + expect((error as Error).message).toContain('wire dropped'); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + }); + expect(unhandled).toEqual([]); + }); +}); + describe('Client.listen() — ack timeout (fake timers)', () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); From f091897f4ba6c0519584382e31b68a6ca935f35b Mon Sep 17 00:00:00 2001 From: Axit Date: Mon, 28 Sep 2026 19:38:27 +0530 Subject: [PATCH 08/24] fix(codemod): keep a file's leading comment block above rewritten imports (#2582) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/codemod-preserve-file-header.md | 5 + .../v1-to-v2/transforms/importPaths.ts | 35 +++- packages/codemod/src/utils/importUtils.ts | 15 +- .../codemod/test/commentInsertion.test.ts | 30 ++++ .../v1-to-v2/transforms/importPaths.test.ts | 152 ++++++++++++++++++ 5 files changed, 225 insertions(+), 12 deletions(-) create mode 100644 .changeset/codemod-preserve-file-header.md diff --git a/.changeset/codemod-preserve-file-header.md b/.changeset/codemod-preserve-file-header.md new file mode 100644 index 0000000000..b686d36080 --- /dev/null +++ b/.changeset/codemod-preserve-file-header.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/codemod': patch +--- + +The `v1-to-v2` codemod now writes rewritten imports where the first v1 import stood, not at the top of the file, so a license header, `// @ts-nocheck`, `/// ` or a `'use client'` / `'use server'` / `'use strict'` directive above it stays in place. Known gap: when a later step of the codemod replaces or removes the import (for example a file whose only SDK import is `ErrorCode` or `StreamableHTTPError`), the new import can still land above or inside the header, and a `/** */` header can be removed. Files already migrated with codemod 2.1.0 or earlier are not repaired; check the top of those files. diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts index 1d05a307f1..f4d338e4b7 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts @@ -52,7 +52,11 @@ export const importPathsTransform: Transform = { return spec.includes('/server/'); }); - const insertIndex = sourceFile.getImportDeclarations().indexOf(sdkImports[0]!); + const importIndex = sourceFile.getImportDeclarations().indexOf(sdkImports[0]!); + // ts-morph inserts by position among all top-level children (own-line comments and statements count), not among imports. + const childIndex = sdkImports[0]!.getChildIndex(); + const previousEnd = childIndex > 0 ? sourceFile.getStatementsWithComments()[childIndex - 1]!.getEnd() : 0; + const blankLineAbove = childIndex > 0 && /^[ \t]*\r?\n[ \t]*\r?\n/.test(sourceFile.getFullText().slice(previousEnd)); // A leading file-header / JSDoc comment attaches to the first SDK import as leading trivia. When // that import is removed and re-emitted (the per-symbol split/merge path calls imp.remove()), @@ -62,9 +66,12 @@ export const importPathsTransform: Transform = { // (a blank line, or CRLF in CRLF files), so the later survival check would never match a header // that actually survived (in-place setModuleSpecifier rewrite) and would re-insert it, duplicating // it. The slice reproduces the block verbatim, so the includes() guard below is byte-exact. - const leadingRanges = sdkImports[0]!.getLeadingCommentRanges(); + // Own-line comments above the import are siblings and survive its removal; only comments attached to it can be dropped. + const leadingRanges = sdkImports[0]!.getLeadingCommentRanges().filter(range => range.getPos() >= previousEnd); const leadingCommentText = leadingRanges.length > 0 ? sourceFile.getFullText().slice(leadingRanges[0]!.getPos(), leadingRanges.at(-1)!.getEnd()) : ''; + const leadingCommentGap = + leadingRanges.length > 0 ? sourceFile.getFullText().slice(leadingRanges.at(-1)!.getEnd(), sdkImports[0]!.getStart()) : ''; interface PendingImport { specs: NamedImportSpec[]; @@ -334,6 +341,10 @@ export const importPathsTransform: Transform = { } } + // New imports go where the first SDK import stood, or right below it when it was rewritten in place and still stands there. + const firstRemoved = sdkImports[0]!.wasForgotten(); + const insertIndex = firstRemoved ? childIndex : childIndex + 1; + const specLocal = (spec: NamedImportSpec): string => (typeof spec === 'string' ? spec : (spec.alias ?? spec.name)); for (const [target, groups] of pendingImports) { // Dedupe by local binding name (alias when present), keeping the spec so aliases survive. @@ -345,12 +356,22 @@ export const importPathsTransform: Transform = { } } + let valueInserted = false; if (valueSpecs.size > 0) { - addOrMergeImport(sourceFile, target, [...valueSpecs.values()], false, insertIndex); + valueInserted = addOrMergeImport( + sourceFile, + target, + [...valueSpecs.values()], + false, + insertIndex, + firstRemoved && blankLineAbove + ); } if (typeOnlySpecs.size > 0) { - const typeInsertIndex = valueSpecs.size > 0 ? insertIndex + 1 : insertIndex; - addOrMergeImport(sourceFile, target, [...typeOnlySpecs.values()], true, typeInsertIndex); + // The type import goes one lower only when the value import was inserted, not when it merged into an existing import. + const typeInsertIndex = valueInserted ? insertIndex + 1 : insertIndex; + const blankLine = firstRemoved && blankLineAbove && !valueInserted; + addOrMergeImport(sourceFile, target, [...typeOnlySpecs.values()], true, typeInsertIndex, blankLine); } } @@ -358,8 +379,8 @@ export const importPathsTransform: Transform = { // the first import was rewritten in place and kept its comment). if (leadingCommentText && !sourceFile.getFullText().includes(leadingCommentText)) { const imports = sourceFile.getImportDeclarations(); - const anchor = imports[Math.min(insertIndex, imports.length - 1)]; - sourceFile.insertText(anchor ? anchor.getStart() : 0, `${leadingCommentText}\n`); + const anchor = imports[Math.min(importIndex, imports.length - 1)]; + sourceFile.insertText(anchor ? anchor.getStart() : 0, `${leadingCommentText}${leadingCommentGap}`); } return { changesCount, diagnostics, usedPackages }; diff --git a/packages/codemod/src/utils/importUtils.ts b/packages/codemod/src/utils/importUtils.ts index e2e6c70a49..24eaa0cf5f 100644 --- a/packages/codemod/src/utils/importUtils.ts +++ b/packages/codemod/src/utils/importUtils.ts @@ -64,14 +64,16 @@ function specLocalName(s: { name: string; alias?: string }): string { return s.alias ?? s.name; } +/** Adds the names to an existing import of the module, or inserts a new import; returns true when it inserted one. */ export function addOrMergeImport( sourceFile: SourceFile, moduleSpecifier: string, namedImports: NamedImportSpec[], isTypeOnly: boolean, - insertIndex: number -): void { - if (namedImports.length === 0) return; + insertIndex: number, + blankLineAbove = false +): boolean { + if (namedImports.length === 0) return false; const specs = namedImports.map(n => toSpec(n)); @@ -86,6 +88,7 @@ export function addOrMergeImport( if (newSpecs.length > 0) { existing.addNamedImports(newSpecs.map(s => (s.alias ? { name: s.name, alias: s.alias } : { name: s.name }))); } + return false; } else { const seen = new Set(); const deduped = specs.filter(s => { @@ -94,12 +97,14 @@ export function addOrMergeImport( seen.add(local); return true; }); - const clampedIndex = Math.min(insertIndex, sourceFile.getImportDeclarations().length); + const clampedIndex = Math.min(insertIndex, sourceFile.getStatementsWithComments().length); sourceFile.insertImportDeclaration(clampedIndex, { moduleSpecifier, namedImports: deduped.map(s => (s.alias ? { name: s.name, alias: s.alias } : { name: s.name })), - isTypeOnly + isTypeOnly, + leadingTrivia: blankLineAbove ? writer => writer.blankLineIfLastNot() : undefined }); + return true; } } diff --git a/packages/codemod/test/commentInsertion.test.ts b/packages/codemod/test/commentInsertion.test.ts index 255cb9e581..689c949363 100644 --- a/packages/codemod/test/commentInsertion.test.ts +++ b/packages/codemod/test/commentInsertion.test.ts @@ -330,6 +330,36 @@ describe('comment insertion', () => { }); describe('markers whose import-declaration anchor is removed by the same pass', () => { + it('keeps a license header first and still writes the marker below it (#2575)', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'app', dependencies: { '@modelcontextprotocol/sdk': '^1.29.0' } }) + ); + const file = path.join(dir, 'auth.ts'); + writeFileSync( + file, + [ + `// Copyright (c) 2026 Example Corp.`, + `// SPDX-License-Identifier: Apache-2.0`, + ``, + `import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';`, + ``, + `export const guard = requireBearerAuth({ verifier });`, + '' + ].join('\n') + ); + + const result = run(migration, { targetDir: dir, dryRun: false }); + + const lines = readFileSync(file, 'utf8').split('\n'); + expect(lines.slice(0, 3)).toEqual(['// Copyright (c) 2026 Example Corp.', '// SPDX-License-Identifier: Apache-2.0', '']); + expect(lines[3]).toBe(`import { requireBearerAuth } from "@modelcontextprotocol/server-legacy/auth";`); + const markerIndex = lines.findIndex(line => line.includes(CODEMOD_ERROR_PREFIX)); + expect(lines[markerIndex + 1]).toContain('requireBearerAuth({ verifier })'); + expect(result.diagnostics.find(d => d.insertComment)?.line).toBe(markerIndex + 1); + }); + it('inserts the resource-server auth helper marker at the usage site', () => { const dir = createTempDir(); writeFileSync( diff --git a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts index 933cf02e29..34fdaf4e4b 100644 --- a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts @@ -204,6 +204,135 @@ describe('import-paths transform', () => { expect(result).toContain('@modelcontextprotocol/server'); }); + it('keeps a license header above the rewritten import, with its blank line intact', () => { + // #2575: the header survived in the text but the re-emitted import was inserted above it, so + // the header stopped being the first thing in the file (breaking eslint-plugin-header / + // SPDX scanners) and the blank line separating it from the code was consumed, turning the + // header into a doc comment for the next declaration. Content-only assertions miss both. + const input = [ + `// Copyright (c) 2026 Example Corp.`, + `// SPDX-License-Identifier: Apache-2.0`, + ``, + `import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';`, + ``, + `export const ok = (): CallToolResult => ({ content: [] });`, + '' + ].join('\n'); + const result = applyTransform(input, { projectType: 'server' }); + const lines = result.split('\n'); + + expect(lines[0]).toBe('// Copyright (c) 2026 Example Corp.'); + expect(lines[1]).toBe('// SPDX-License-Identifier: Apache-2.0'); + // the blank line between header and code must survive, or the header reads as a doc comment + expect(lines[2]).toBe(''); + expect(lines.findIndex(l => l.startsWith('import'))).toBeGreaterThan(1); + expect(result).toContain('@modelcontextprotocol/server'); + expect(result).toBe(input.replace(`'@modelcontextprotocol/sdk/types.js'`, `"@modelcontextprotocol/server"`)); + }); + + it('does not split a multi-line header run when the SDK import is not the first import', () => { + // #2575, second shape: the rewritten import was inserted *inside* the leading `//` run, + // stranding line 1 above an unrelated import. + const input = [ + `// page_to_markdown tool: fetches a URL and returns clean Markdown.`, + `// Uses @page2ai/core under the hood - inherits SSRF protection and a 10MB cap.`, + `// Static tab discovery emits per-tab sections for docs sites.`, + ``, + `import { fetchAndConvert } from '@page2ai/core';`, + `import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';`, + ``, + `export const x = (r: CallToolResult) => fetchAndConvert(r);`, + '' + ].join('\n'); + const result = applyTransform(input, { projectType: 'server' }); + const lines = result.split('\n'); + + // the three header lines stay contiguous at the top of the file + expect(lines[0]).toContain('page_to_markdown tool'); + expect(lines[1]).toContain('@page2ai/core under the hood'); + expect(lines[2]).toContain('Static tab discovery'); + expect(lines.findIndex(l => l.startsWith('import'))).toBeGreaterThan(2); + expect(result).toContain('@modelcontextprotocol/server'); + expect(result).toBe(input.replace(`'@modelcontextprotocol/sdk/types.js'`, `"@modelcontextprotocol/server"`)); + }); + + it('leaves a comment above a mid-file import attached to that import', () => { + // A comment above a later import documents that import: it stays directly above it. + const input = [ + `import { fetchAndConvert } from '@page2ai/core';`, + ``, + `// Result type returned to the caller.`, + `import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';`, + ``, + `export const x = (r: CallToolResult) => fetchAndConvert(r);`, + '' + ].join('\n'); + const result = applyTransform(input, { projectType: 'server' }); + + expect(result.split('// Result type returned to the caller.').length - 1).toBe(1); + expect(result.indexOf('// Result type returned to the caller.')).toBeGreaterThan(result.indexOf('@page2ai/core')); + expect(result).toBe(input.replace(`'@modelcontextprotocol/sdk/types.js'`, `"@modelcontextprotocol/server"`)); + }); + + it(`keeps a 'use client' directive above the rewritten import`, () => { + const input = [ + `'use client';`, + ``, + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, + `import { useState } from 'react';`, + '' + ].join('\n'); + const expected = input.replace(`'@modelcontextprotocol/sdk/client/index.js'`, `"@modelcontextprotocol/client"`); + expect(applyTransform(input)).toBe(expected); + }); + + it('keeps the blank line below a JSDoc-style license header', () => { + const input = [ + `/**`, + ` * @license Apache-2.0`, + ` */`, + ``, + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + ``, + `export const server = new McpServer({ name: 'x', version: '1.0.0' });`, + '' + ].join('\n'); + const expected = input.replace(`'@modelcontextprotocol/sdk/server/mcp.js'`, `"@modelcontextprotocol/server"`); + expect(applyTransform(input)).toBe(expected); + }); + + it('does not duplicate a license header that is followed by a JSDoc block', () => { + const input = [ + `// Copyright (c) 2026 Example Corp.`, + ``, + `/**`, + ` * What this file does.`, + ` */`, + ``, + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + ``, + `export const server = new McpServer({ name: 'x', version: '1.0.0' });`, + '' + ].join('\n'); + const expected = input.replace(`'@modelcontextprotocol/sdk/server/mcp.js'`, `"@modelcontextprotocol/server"`); + expect(applyTransform(input)).toBe(expected); + }); + + it('puts new imports below a namespace import that is rewritten in place, not above the header', () => { + const input = [ + `/** @license Apache-2.0 */`, + ``, + `import * as types from '@modelcontextprotocol/sdk/types.js';`, + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + '' + ].join('\n'); + const lines = applyTransform(input, { projectType: 'server' }).split('\n'); + expect(lines[0]).toBe(`/** @license Apache-2.0 */`); + expect(lines[1]).toBe(''); + expect(lines[2]).toContain('* as types'); + expect(lines[3]).toBe(`import { McpServer } from "@modelcontextprotocol/server";`); + }); + it('routes OAuth *Schema from sdk/shared/auth.js to core; the TYPE resolves by context', () => { // OAuthTokensSchema is a Zod schema re-exported by core (AUTH_SCHEMA_NAMES), so route it // there — `OAuthTokensSchema.parse(...)` keeps working. OAuthTokens (the type) has no schema-name @@ -766,6 +895,29 @@ describe('import-paths transform', () => { const output = sourceFile.getFullText(); expect(output).toContain('@modelcontextprotocol/client'); expect(output).not.toContain('@modelcontextprotocol/sdk'); + const lines = output.split('\n'); + expect(lines[0]).toBe(`import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';`); + expect(lines[1]).toBe(`import type { Tool } from "@modelcontextprotocol/client";`); + expect(lines.findLastIndex(line => line.startsWith('import '))).toBeLessThan(lines.findIndex(line => line.startsWith('const c'))); + }); + + it('keeps the type import with the imports when the value import merges into an existing v2 import', () => { + const input = [ + `// Copyright (c) 2026 Example Corp.`, + `// SPDX-License-Identifier: Apache-2.0`, + ``, + `import { Client } from '@modelcontextprotocol/client';`, + `import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';`, + `import type { Tool } from '@modelcontextprotocol/sdk/types.js';`, + ``, + `const c = new Client({});`, + '' + ].join('\n'); + const lines = applyTransform(input, { projectType: 'client' }).split('\n'); + expect(lines.slice(0, 3)).toEqual(['// Copyright (c) 2026 Example Corp.', '// SPDX-License-Identifier: Apache-2.0', '']); + expect(lines[3]).toBe(`import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';`); + expect(lines[4]).toBe(`import type { Tool } from "@modelcontextprotocol/client";`); + expect(lines.findLastIndex(line => line.startsWith('import '))).toBeLessThan(lines.findIndex(line => line.startsWith('const c'))); }); it('applies SIMPLE_RENAMES to re-export specifiers', () => { From e3fb9edcdec70ed7c8463548ea9b90bbcb74c4ab Mon Sep 17 00:00:00 2001 From: Vijay Misal Date: Mon, 28 Sep 2026 19:51:38 +0530 Subject: [PATCH 09/24] fix(server): restore onclose after modern exchanges (#2778) Signed-off-by: vjymisal0 Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/curvy-rivers-restore.md | 5 ++ .../server/src/server/createMcpHandler.ts | 7 ++- .../test/server/createMcpHandler.test.ts | 47 +++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 .changeset/curvy-rivers-restore.md diff --git a/.changeset/curvy-rivers-restore.md b/.changeset/curvy-rivers-restore.md new file mode 100644 index 0000000000..1c15f14631 --- /dev/null +++ b/.changeset/curvy-rivers-restore.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +Fix a stack overflow in `createMcpHandler` when the factory returns the same server instance for more than one request. Returning a fresh instance per request is still required. diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 82cfa061bc..52ac41fd20 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -867,10 +867,15 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa // Track the instance until its exchange tears down so close() can abort it. const previousOnClose = server.onclose; inflight.add(server); - server.onclose = () => { + const onExchangeClose = () => { inflight.delete(server); + // Restore by identity so a handler installed during the exchange is kept. + if (server.onclose === onExchangeClose) { + server.onclose = previousOnClose; + } previousOnClose?.(); }; + server.onclose = onExchangeClose; try { const response = await invoke(product, route.message, { diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index 4dae5c0ad7..8de6672f49 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -274,6 +274,53 @@ describe('createMcpHandler — modern path', () => { expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: 'factory exploded' })); }); + it('restores a reused server onclose handler after each modern exchange', async () => { + const reused = new McpServer({ name: 'entry-test-server', version: '1.0.0' }); + reused.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] + })); + + const originalOnClose = vi.fn(); + reused.server.onclose = originalOnClose; + + const handler = createMcpHandler(() => reused); + + for (let i = 0; i < 3; i++) { + const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: `hello-${i}` }))); + expect(response.status).toBe(200); + await response.text(); + expect(reused.server.onclose).toBe(originalOnClose); + } + + expect(originalOnClose).toHaveBeenCalledTimes(3); + }); + + it('keeps an onclose handler that was installed during the exchange', async () => { + const reused = new McpServer({ name: 'entry-test-server', version: '1.0.0' }); + const installedDuringExchange = vi.fn(); + let chained: (() => void) | undefined; + reused.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => { + if (chained === undefined) { + const previous = reused.server.onclose; + chained = () => { + installedDuringExchange(); + previous?.(); + }; + reused.server.onclose = chained; + } + return { content: [{ type: 'text', text }] }; + }); + + const handler = createMcpHandler(() => reused); + + const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'hello' }))); + expect(response.status).toBe(200); + await response.text(); + + expect(reused.server.onclose).toBe(chained); + expect(installedDuringExchange).toHaveBeenCalledTimes(1); + }); + it('closes and releases the per-request instance when a modern exchange fails internally', async () => { const { factory, state } = testFactory(); const onerror = vi.fn(); From c0f7aec2925a46b32dc2f2aad5bf636a36f02c78 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:27:11 +0100 Subject: [PATCH 10/24] fix(client): inline jose types in the CommonJS declaration file (#2883) Co-authored-by: Claude Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/client-cjs-types-inline-jose.md | 5 +++ packages/client/tsdown.config.ts | 2 +- scripts/smoke-dist-types.mjs | 44 +++++++++++++++++++++- 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 .changeset/client-cjs-types-inline-jose.md diff --git a/.changeset/client-cjs-types-inline-jose.md b/.changeset/client-cjs-types-inline-jose.md new file mode 100644 index 0000000000..5c2113c5ca --- /dev/null +++ b/.changeset/client-cjs-types-inline-jose.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Fix a type-check failure for CommonJS TypeScript projects introduced in 2.1.0: `dist/index.d.cts` imported types from `jose`, which is ESM-only, so `tsc` with `module: node16`/`node18` and `skipLibCheck: false` failed with TS1479. The two `jose` types used by the DPoP API (`CryptoKey`, `JWK`) are now inlined into the declaration files. No runtime change. diff --git a/packages/client/tsdown.config.ts b/packages/client/tsdown.config.ts index 45e0d7a28e..1a1229fedf 100644 --- a/packages/client/tsdown.config.ts +++ b/packages/client/tsdown.config.ts @@ -21,7 +21,7 @@ export default defineConfig({ shims: true, dts: { resolver: 'tsc', - resolve: ['ajv', 'ajv-formats', 'json-schema-typed'], + resolve: ['ajv', 'ajv-formats', 'json-schema-typed', 'jose'], compilerOptions: { baseUrl: '.', paths: { diff --git a/scripts/smoke-dist-types.mjs b/scripts/smoke-dist-types.mjs index 7effbb92cd..71584b92cc 100644 --- a/scripts/smoke-dist-types.mjs +++ b/scripts/smoke-dist-types.mjs @@ -3,7 +3,7 @@ // bundler emits only as non-fatal warnings (its failOnWarn does not fail on // MISSING_EXPORT). Run after `pnpm build:all`. import { execFileSync } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -65,6 +65,48 @@ try { ); execFileSync('pnpm', ['exec', 'tsc', '-p', dir], { cwd: repo, stdio: 'inherit' }); console.log('dist-types smoke: clean (skipLibCheck: false)'); + + // CommonJS consumer: resolves through the package `exports` map to the .d.cts files, which must not import ESM-only packages. + const cjs = path.join(dir, 'cjs'); + mkdirSync(path.join(cjs, 'node_modules', '@modelcontextprotocol'), { recursive: true }); + for (const pkg of ['client', 'server']) { + symlinkSync(path.join(repo, 'packages', pkg), path.join(cjs, 'node_modules', '@modelcontextprotocol', pkg), 'dir'); + } + writeFileSync( + path.join(cjs, 'consumer.cts'), + [ + "import { Client } from '@modelcontextprotocol/client';", + "import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';", + "import { McpServer } from '@modelcontextprotocol/server';", + "import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';", + "export const c = new Client({ name: 'smoke', version: '1.0.0' });", + "export const s = new McpServer({ name: 'smoke', version: '1.0.0' });", + 'export { StdioClientTransport, StdioServerTransport };', + '' + ].join('\n') + ); + writeFileSync( + path.join(cjs, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + strict: true, + noEmit: true, + skipLibCheck: false, + module: 'node16', + moduleResolution: 'node16', + target: 'es2022', + types: ['node'], + typeRoots: [path.join(repo, 'node_modules', '@types')] + }, + include: ['consumer.cts'] + }, + null, + 2 + ) + ); + execFileSync('pnpm', ['exec', 'tsc', '-p', cjs], { cwd: repo, stdio: 'inherit' }); + console.log('dist-types smoke (CommonJS consumer): clean (skipLibCheck: false)'); } finally { rmSync(dir, { recursive: true, force: true }); } From d992df724723c30305a573a5087c2743d23acd9f Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:32:00 +0100 Subject: [PATCH 11/24] chore: remove CODEOWNERS patterns that match nothing on main (#2884) Co-authored-by: Claude Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .github/CODEOWNERS | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 596e6991d4..fe03e038d9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,10 +2,3 @@ # Default owners for everything in the repo * @modelcontextprotocol/typescript-sdk - -# Auth team owns all auth-related code -/src/server/auth/ @modelcontextprotocol/typescript-sdk-auth -/src/client/auth* @modelcontextprotocol/typescript-sdk-auth -/src/shared/auth* @modelcontextprotocol/typescript-sdk-auth -/src/examples/client/simpleOAuthClient.ts @modelcontextprotocol/typescript-sdk-auth -/src/examples/server/demoInMemoryOAuthProvider.ts @modelcontextprotocol/typescript-sdk-auth \ No newline at end of file From efebf5b2ba2f07c6fa27ada824a0999b801fec0a Mon Sep 17 00:00:00 2001 From: WellAbin <62972474+web-abin@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:07:26 +0800 Subject: [PATCH 12/24] docs(client): correct token endpoint TLS citation (#2768) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/correct-token-endpoint-tls-citation.md | 5 +++++ packages/client/src/client/auth.ts | 7 +++++-- packages/client/src/client/authErrors.ts | 10 ++++++---- 3 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 .changeset/correct-token-endpoint-tls-citation.md diff --git a/.changeset/correct-token-endpoint-tls-citation.md b/.changeset/correct-token-endpoint-tls-citation.md new file mode 100644 index 0000000000..50dd1d3220 --- /dev/null +++ b/.changeset/correct-token-endpoint-tls-citation.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Correct the JSDoc for insecure OAuth token endpoints. The TLS requirement comes from the MCP authorization specification's OAuth 2.1 communication-security rules, not SEP-2207, which covers OIDC-flavored refresh-token guidance. Documentation only; no runtime behavior change. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 6426d8dcb2..ce6b5f2040 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -875,8 +875,11 @@ function isLoopbackHost(hostname: string): boolean { } /** - * SEP-2207: refuse to send credentials to a non-TLS, non-loopback token endpoint. - * Throws {@linkcode InsecureTokenEndpointError}. Loopback hosts are exempt. + * Refuse to send credentials to a non-TLS token endpoint. The MCP authorization + * specification requires authorization server endpoints to use HTTPS. Loopback + * hosts are exempt here for local development. + * + * @throws {@linkcode InsecureTokenEndpointError} if the endpoint is insecure. */ export function assertSecureTokenEndpoint(tokenEndpoint: string | URL): URL { const url = new URL(String(tokenEndpoint)); diff --git a/packages/client/src/client/authErrors.ts b/packages/client/src/client/authErrors.ts index 5afa88413d..98b2c57c32 100644 --- a/packages/client/src/client/authErrors.ts +++ b/packages/client/src/client/authErrors.ts @@ -132,10 +132,12 @@ export class RegistrationRejectedError extends OAuthClientFlowError { /** * Thrown by the token-exchange and refresh paths when the resolved token - * endpoint is not `https:` and is not a loopback host (SEP-2207). This is a - * configuration error — re-authorizing cannot fix it — so it intentionally does - * **not** extend `OAuthError` and `auth()`'s refresh branch rethrows it instead - * of falling through to a fresh `/authorize` redirect. + * endpoint is not `https:` and is not a loopback host. The MCP authorization + * specification requires authorization server endpoints to use HTTPS; loopback + * hosts are exempt here for local development. This is a configuration error — + * re-authorizing cannot fix it — so it intentionally does **not** extend + * `OAuthError` and `auth()`'s refresh branch rethrows it instead of falling + * through to a fresh `/authorize` redirect. */ export class InsecureTokenEndpointError extends OAuthClientFlowError { static { From a4ae2f98da3814a9290f2791cfcca8148dcec978 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:12:05 +0100 Subject: [PATCH 13/24] docs(client): correct the registerClient deprecation citation to spec PR #2858 (#2729) Co-authored-by: Claude Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/dcr-deprecation-citation.md | 5 +++++ docs/migration/upgrade-to-v2.md | 6 ++++-- packages/client/src/client/auth.ts | 15 +++++++++++---- 3 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 .changeset/dcr-deprecation-citation.md diff --git a/.changeset/dcr-deprecation-citation.md b/.changeset/dcr-deprecation-citation.md new file mode 100644 index 0000000000..14cb84d7e4 --- /dev/null +++ b/.changeset/dcr-deprecation-citation.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Correct the `registerClient` `@deprecated` notice: Dynamic Client Registration was deprecated by spec PR modelcontextprotocol#2858 (Client ID Metadata Documents), not SEP-2577 (which deprecates roots, sampling, and logging). The notice now also names the earliest possible removal date under the feature lifecycle policy (2027-07-28) and clarifies that the `client_id_metadata_document_supported` gating lives in the built-in `auth()` flow — `registerClient` called directly always sends the registration request. Documentation only; no runtime behavior change. diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index fa8f2f2175..5b0310b762 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -484,8 +484,10 @@ instead. `ToolChoice`, `ToolUseContent`/`ToolResultContent`, the `includeContext` enum values), and the full Roots stack (`Root`, `ListRootsRequest`/`Result`, `RootsListChangedNotification`). -- **`registerClient`** (Dynamic Client Registration) — prefer Client ID Metadata - Documents per SEP-991. +- **`registerClient`** (Dynamic Client Registration) — deprecated via spec PR + [modelcontextprotocol#2858](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2858) + rather than SEP-2577 (listed here because the `@deprecated` annotations landed in + the same sweep); prefer Client ID Metadata Documents per SEP-991. The deprecation is annotation-only — JSDoc `@deprecated` markers were added, nothing else: every deprecated runtime API keeps its v1 call signature (e.g. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index ce6b5f2040..a3fa18f054 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -2484,10 +2484,17 @@ export async function fetchToken( * consistently across both DCR and the subsequent authorization request. * * @deprecated Dynamic Client Registration is deprecated as of protocol version - * 2026-07-28 (SEP-2577) in favor of Client ID Metadata Documents (SEP-991). - * Remains functional during the deprecation window (at least twelve months). - * Prefer a CIMD URL `client_id` when the authorization server advertises - * `client_id_metadata_document_supported`; the SDK already gates on this for you. + * 2026-07-28 in favor of Client ID Metadata Documents (SEP-991); the deprecation + * landed via spec PR + * {@link https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2858 | modelcontextprotocol#2858} + * (SEP-2577 is the separate roots/sampling/logging deprecation). Remains + * functional during the deprecation window — at least twelve months under the + * feature lifecycle policy (SEP-2596), so 2027-07-28 is the earliest possible + * removal date. Prefer a CIMD URL `client_id` when the authorization server + * advertises `client_id_metadata_document_supported`: the built-in `auth()` flow + * skips registration for you when that capability is advertised AND your + * provider supplies `clientMetadataUrl`, but `registerClient` itself does not + * gate — calling it directly always sends the registration request. */ export async function registerClient( authorizationServerUrl: string | URL, From e780e13869ad419a864b669bd5fa9702cfb36b14 Mon Sep 17 00:00:00 2001 From: Syed Tashfin Date: Mon, 28 Sep 2026 17:29:50 +0200 Subject: [PATCH 14/24] fix(client): preserve _meta on input_required results (#2862) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/input-required-meta-passthrough.md | 5 ++++ .../test/client/inputRequiredEngine.test.ts | 27 +++++++++++++++++++ .../src/shared/inputRequiredEngine.ts | 11 +++++--- packages/core-internal/src/wire/codec.ts | 3 +++ .../src/wire/rev2026-07-28/codec.ts | 8 ++++-- .../test/shared/inputRequiredFunnel.test.ts | 23 ++++++++++++++++ 6 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 .changeset/input-required-meta-passthrough.md diff --git a/.changeset/input-required-meta-passthrough.md b/.changeset/input-required-meta-passthrough.md new file mode 100644 index 0000000000..6f689097e7 --- /dev/null +++ b/.changeset/input-required-meta-passthrough.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Preserve `_meta` on `input_required` results. The 2026-07-28 decode seam rebuilt the payload from `inputRequests` and `requestState` only, so result-level metadata a server sent on an `input_required` result (including `io.modelcontextprotocol/serverInfo`) was dropped before an `allowInputRequired: true` caller could see it. `Result._meta` is a result-level field, so `input_required` carries it exactly like any other result. diff --git a/packages/client/test/client/inputRequiredEngine.test.ts b/packages/client/test/client/inputRequiredEngine.test.ts index e2d8c16dde..e048ea6bc2 100644 --- a/packages/client/test/client/inputRequiredEngine.test.ts +++ b/packages/client/test/client/inputRequiredEngine.test.ts @@ -371,4 +371,31 @@ describe('manual mode', () => { await client.close(); }); + + it('carries the result-level _meta through to the manual caller', async () => { + const { clientTx } = await scriptedModernServer(() => ({ + resultType: 'input_required', + inputRequests: { github_login: ELICIT_ENTRY }, + requestState: 'manual-state', + _meta: { + 'io.modelcontextprotocol/serverInfo': { name: 'scripted-mrtr-server', version: '1.0.0' }, + 'example.com/displayHint': 'inline' + } + })); + + const client = makeClient({ inputRequired: { autoFulfill: false } }); + await client.connect(clientTx); + + const first = (await client.callTool({ name: 'deploy', arguments: {} }, { allowInputRequired: true })) as unknown as Record< + string, + unknown + >; + expect(first.resultType).toBe('input_required'); + expect(first._meta).toEqual({ + 'io.modelcontextprotocol/serverInfo': { name: 'scripted-mrtr-server', version: '1.0.0' }, + 'example.com/displayHint': 'inline' + }); + + await client.close(); + }); }); diff --git a/packages/core-internal/src/shared/inputRequiredEngine.ts b/packages/core-internal/src/shared/inputRequiredEngine.ts index cc8fec2c4b..03d6319d98 100644 --- a/packages/core-internal/src/shared/inputRequiredEngine.ts +++ b/packages/core-internal/src/shared/inputRequiredEngine.ts @@ -13,7 +13,7 @@ * `droppedInputResponseKeys` context fields), and the named extension point. */ import { SdkError, SdkErrorCode } from '../errors/sdkErrors'; -import type { InputRequiredResult, JSONRPCRequest, RequestMeta, Result } from '../types/types'; +import type { InputRequiredResult, JSONRPCRequest, RequestMeta, Result, ResultMetaObject } from '../types/types'; import type { StandardSchemaV1 } from '../util/standardSchema'; import type { WireCodec } from '../wire/codec'; import type { @@ -237,10 +237,15 @@ export function runInputRequiredFlow( * codec's decoded payload — what an `allowInputRequired: true` caller * receives instead of the auto-fulfilled complete result. */ -export function manualInputRequiredValue(decoded: { inputRequests: Record; requestState?: string }): InputRequiredResult { +export function manualInputRequiredValue(decoded: { + inputRequests: Record; + requestState?: string; + _meta?: ResultMetaObject; +}): InputRequiredResult { return { resultType: 'input_required', inputRequests: decoded.inputRequests as InputRequiredResult['inputRequests'], - ...(decoded.requestState !== undefined && { requestState: decoded.requestState }) + ...(decoded.requestState !== undefined && { requestState: decoded.requestState }), + ...(decoded._meta !== undefined && { _meta: decoded._meta }) }; } diff --git a/packages/core-internal/src/wire/codec.ts b/packages/core-internal/src/wire/codec.ts index 7672a8d64a..cf545c2ebc 100644 --- a/packages/core-internal/src/wire/codec.ts +++ b/packages/core-internal/src/wire/codec.ts @@ -57,6 +57,7 @@ import type { RequestMethod, RequestTypeMap, Result, + ResultMetaObject, ResultTypeMap } from '../types/types'; import { rev2025Codec } from './rev2025-11-25/codec'; @@ -147,6 +148,8 @@ export type DecodedResult = */ inputRequests: Record; requestState?: string; + /** Result-level `_meta`, carried through so a manual caller sees it. */ + _meta?: ResultMetaObject; } | { kind: 'invalid'; error: SdkError }; diff --git a/packages/core-internal/src/wire/rev2026-07-28/codec.ts b/packages/core-internal/src/wire/rev2026-07-28/codec.ts index 9bc6c4d737..c26d5ce467 100644 --- a/packages/core-internal/src/wire/rev2026-07-28/codec.ts +++ b/packages/core-internal/src/wire/rev2026-07-28/codec.ts @@ -29,7 +29,7 @@ import type * as z from 'zod/v4'; import { SdkError, SdkErrorCode } from '../../errors/sdkErrors'; import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, LOG_LEVEL_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../types/constants'; -import type { CallToolResult, Implementation, Result } from '../../types/types'; +import type { CallToolResult, Implementation, Result, ResultMetaObject } from '../../types/types'; import type { DecodedResult, EnvelopeIssue, LiftedWireMaterial, OutboundEnvelopeMaterial, ValidateOutcome, WireCodec } from '../codec'; import { appendTextFallbackForNonObject } from '../textFallback'; import { buildSchemas2026 } from './buildSchemas'; @@ -202,6 +202,9 @@ export const rev2026Codec: WireCodec & { const rawInputRequests = raw['inputRequests']; const inputRequests = isPlainObject(rawInputRequests) ? rawInputRequests : {}; const requestState = raw['requestState']; + // Carry result-level `_meta` through, parsed as on complete results (malformed drops to absent). + const metaParse = raw['_meta'] === undefined ? undefined : buildSchemas2026().ResultMetaSchema.safeParse(raw['_meta']); + const meta = metaParse?.success ? (metaParse.data as ResultMetaObject) : undefined; if (Object.keys(inputRequests).length === 0 && typeof requestState !== 'string') { // At-least-one rule, client side: with neither inputRequests // nor requestState there is nothing to fulfil and nothing to @@ -220,7 +223,8 @@ export const rev2026Codec: WireCodec & { return { kind: 'input_required', inputRequests, - ...(typeof requestState === 'string' && { requestState }) + ...(typeof requestState === 'string' && { requestState }), + ...(meta !== undefined && { _meta: meta }) }; } if (rawResultType !== 'complete') { diff --git a/packages/core-internal/test/shared/inputRequiredFunnel.test.ts b/packages/core-internal/test/shared/inputRequiredFunnel.test.ts index 85642f8f43..84ccdea58f 100644 --- a/packages/core-internal/test/shared/inputRequiredFunnel.test.ts +++ b/packages/core-internal/test/shared/inputRequiredFunnel.test.ts @@ -67,6 +67,29 @@ describe('manual mode (allowInputRequired)', () => { await protocol.close(); }); + test('preserves the result-level _meta on the handed-back value', async () => { + const protocol = await wireWithRawResult({ + ...INPUT_REQUIRED_BODY, + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'peer', version: '1.0.0' }, 'example.com/displayHint': 'inline' } + }); + + const result = await protocol.request( + { method: 'tools/call', params: { name: 'echo', arguments: {} } }, + { + allowInputRequired: true + } + ); + + // `Result._meta` is a result-level field, so an `input_required` result + // carries it like any other complete result would. + expect((result as Record)['_meta']).toEqual({ + 'io.modelcontextprotocol/serverInfo': { name: 'peer', version: '1.0.0' }, + 'example.com/displayHint': 'inline' + }); + + await protocol.close(); + }); + test('discrimination happens on the raw body, before the consumer-provided result schema runs', async () => { const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY); From 9dd722fd0b533b3cb44cd2834409f7ea4dd32b00 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:53:16 +0100 Subject: [PATCH 15/24] fix(core): await the notification send so a failed send is never briefly unhandled (#2885) Co-authored-by: Claude Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/await-notification-send.md | 6 ++ ...egacyHandshakeCloseAfterInitialize.test.ts | 82 +++++++++++++++++++ packages/core-internal/src/shared/protocol.ts | 2 +- .../shared/notificationSendRejection.test.ts | 82 +++++++++++++++++++ .../server/notificationSendRejection.test.ts | 52 ++++++++++++ 5 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 .changeset/await-notification-send.md create mode 100644 packages/client/test/client/legacyHandshakeCloseAfterInitialize.test.ts create mode 100644 packages/core-internal/test/shared/notificationSendRejection.test.ts create mode 100644 packages/server/test/server/notificationSendRejection.test.ts diff --git a/.changeset/await-notification-send.md b/.changeset/await-notification-send.md new file mode 100644 index 0000000000..2df131c33c --- /dev/null +++ b/.changeset/await-notification-send.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +Sending a notification on a closed connection no longer produces a briefly unhandled promise rejection (seen as `unhandledrejection` on Cloudflare Workers) in addition to the returned rejection. diff --git a/packages/client/test/client/legacyHandshakeCloseAfterInitialize.test.ts b/packages/client/test/client/legacyHandshakeCloseAfterInitialize.test.ts new file mode 100644 index 0000000000..5288d0b8d9 --- /dev/null +++ b/packages/client/test/client/legacyHandshakeCloseAfterInitialize.test.ts @@ -0,0 +1,82 @@ +/** + * The trigger reported in #2864: the transport closes during the legacy + * `initialize` handshake, right after the server's result is delivered, so the + * client's `notifications/initialized` is sent on a connection that is already + * gone. `connect()` must reject through its returned promise with the + * not-connected send failure, and nothing else may escape. + * + * Node's rejection tracker does not report the one-microtask handler gap that + * workerd does (see core-internal's notificationSendRejection test for the + * timing observation itself); this end-to-end test pins the trigger path and + * the error `connect()` rejects with. + */ +import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal'; +import { isJSONRPCRequest, SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; + +import { Client } from '../../src/client/client'; + +/** Answers `initialize`, then closes in the same tick — before the client can send `notifications/initialized`. */ +class ReplyThenCloseTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + + sent: JSONRPCMessage[] = []; + closeCalls = 0; + + async start(): Promise {} + + async send(message: JSONRPCMessage): Promise { + this.sent.push(message); + if (!isJSONRPCRequest(message) || message.method !== 'initialize') return; + queueMicrotask(() => { + this.onmessage?.({ + jsonrpc: '2.0', + id: message.id, + result: { protocolVersion: '2025-03-26', capabilities: {}, serverInfo: { name: 'flaky', version: '0' } } + }); + this.onclose?.(); + }); + } + + async close(): Promise { + this.closeCalls++; + } +} + +describe('legacy handshake: transport closes between the initialize result and notifications/initialized', () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + + beforeEach(() => { + unhandled.length = 0; + process.on('unhandledRejection', onUnhandled); + }); + + afterEach(() => { + process.off('unhandledRejection', onUnhandled); + }); + + test('connect() rejects with SdkError NotConnected from the initialized-notification send and no rejection escapes', async () => { + const transport = new ReplyThenCloseTransport(); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'legacy' } }); + + const rejection = await client.connect(transport).then( + () => undefined, + (error: unknown) => error + ); + + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.NotConnected); + // The handshake got as far as the send that failed: initialize went out, + // the initialized notification never did. + expect(transport.sent.map(m => ('method' in m ? m.method : 'response'))).toEqual(['initialize']); + + // Let any stray rejection surface before asserting none did. + await new Promise(resolve => setTimeout(resolve, 0)); + expect(unhandled).toEqual([]); + }); +}); diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 637be389aa..8e0bc04034 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -1593,7 +1593,7 @@ export abstract class Protocol { * Emits a notification, which is a one-way message that does not expect a response. */ async notification(notification: Notification, options?: NotificationOptions): Promise { - return this._notificationViaCodec(this._resolveOutboundCodec(notification.method), notification, options); + return await this._notificationViaCodec(this._resolveOutboundCodec(notification.method), notification, options); } /** diff --git a/packages/core-internal/test/shared/notificationSendRejection.test.ts b/packages/core-internal/test/shared/notificationSendRejection.test.ts new file mode 100644 index 0000000000..88129278bb --- /dev/null +++ b/packages/core-internal/test/shared/notificationSendRejection.test.ts @@ -0,0 +1,82 @@ +/** + * `Protocol.notification()` must hand its caller the ONLY rejection a failed + * send produces. The send funnel (`_notificationViaCodec`) is an async method + * that throws synchronously when there is no transport, so the promise it + * returns is already rejected. If `notification()` returns that promise + * instead of awaiting it, the async function resolves with a thenable: its + * `then` is read synchronously at return, but the call is deferred to the + * thenable job, so the inner rejection sits with no handler for one + * microtask. Node's tracker forgives that; + * workerd (Cloudflare Workers) reports it as `unhandledrejection` followed by + * `rejectionhandled`, which surfaces as noise in Vitest runs on that platform + * (#2864). + * + * The observation below is the handler-attachment timing itself: with + * `return await`, `await` attaches its reaction synchronously through the + * internal promise path and the inner promise's own `then` property is never + * read; with a bare `return`, `then` is read at return and called one + * microtask later. + */ +import { describe, expect, test } from 'vitest'; + +import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors'; +import type { BaseContext } from '../../src/shared/protocol'; +import { Protocol } from '../../src/shared/protocol'; + +class TestProtocolImpl extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +/** + * A natively rejected promise whose `then` property records every read. + * Resolving an async function with a thenable reaches `then` via property + * lookup; `await` on a native promise does not. + */ +function instrumentedRejection(error: Error): { promise: Promise; thenReads: () => number } { + const promise = Promise.reject(error); + const originalThen = promise.then.bind(promise); + let reads = 0; + Object.defineProperty(promise, 'then', { + get() { + reads++; + return originalThen; + } + }); + return { promise, thenReads: () => reads }; +} + +describe('Protocol.notification(): a failed send rejects only through the returned promise', () => { + test('when not connected, the inner send rejection is handled in the same microtask (no thenable-job hop)', async () => { + const protocol = new TestProtocolImpl(); + const notConnected = new SdkError(SdkErrorCode.NotConnected, 'Not connected'); + const inner = instrumentedRejection(notConnected); + + // Stand in for the real funnel with a rejection we can observe. The real + // one throws synchronously on `!this._transport`, i.e. it also returns an + // already-rejected promise — the shape that matters here. + // Plain instance override rather than `vi.spyOn`: the spy wrapper itself + // reads `then` on any promise a spied call returns, which would mask the + // observation below. + (protocol as unknown as { _notificationViaCodec: () => Promise })._notificationViaCodec = () => inner.promise; + + await expect(protocol.notification({ method: 'notifications/initialized' })).rejects.toBe(notConnected); + + // A bare `return innerPromise` from the async method reads `then` + // synchronously at return and calls it one microtask later — the window + // in which workerd reports the inner rejection unhandled. + expect(inner.thenReads()).toBe(0); + }); + + test('when not connected, the returned promise still rejects with SdkError NotConnected (unstubbed path)', async () => { + const protocol = new TestProtocolImpl(); + + await expect(protocol.notification({ method: 'notifications/initialized' })).rejects.toSatisfy( + (error: unknown) => error instanceof SdkError && error.code === SdkErrorCode.NotConnected + ); + }); +}); diff --git a/packages/server/test/server/notificationSendRejection.test.ts b/packages/server/test/server/notificationSendRejection.test.ts new file mode 100644 index 0000000000..3ff02f0909 --- /dev/null +++ b/packages/server/test/server/notificationSendRejection.test.ts @@ -0,0 +1,52 @@ +/** + * Server-side twin of core-internal's notificationSendRejection test: a + * `sendLoggingMessage()` on a server with no transport must reject ONLY + * through the returned promise. The send funnel returns an already-rejected + * promise; if `Protocol.notification()` returned it instead of awaiting it, + * the inner rejection would sit unhandled for one microtask (reported by + * workerd as `unhandledrejection` + `rejectionhandled`, #2864). + */ +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal'; +import { describe, expect, test } from 'vitest'; + +import { Server } from '../../src/server/server'; + +function instrumentedRejection(error: Error): { promise: Promise; thenReads: () => number } { + const promise = Promise.reject(error); + const originalThen = promise.then.bind(promise); + let reads = 0; + Object.defineProperty(promise, 'then', { + get() { + reads++; + return originalThen; + } + }); + return { promise, thenReads: () => reads }; +} + +describe('Server notification sends on a closed connection', () => { + test('sendLoggingMessage() when not connected: the inner send rejection is handled in the same microtask', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { logging: {} } }); + const notConnected = new SdkError(SdkErrorCode.NotConnected, 'Not connected'); + const inner = instrumentedRejection(notConnected); + + // Plain instance override rather than `vi.spyOn`: the spy wrapper itself + // reads `then` on any promise a spied call returns, which would mask the + // observation below. + (server as unknown as { _notificationViaCodec: () => Promise })._notificationViaCodec = () => inner.promise; + + await expect(server.sendLoggingMessage({ level: 'info', data: 'hello' })).rejects.toBe(notConnected); + + // `then` is read (synchronously at return, called one microtask later) + // only when `notification()` returns the inner promise without awaiting it. + expect(inner.thenReads()).toBe(0); + }); + + test('sendLoggingMessage() when not connected still rejects with SdkError NotConnected (unstubbed path)', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { logging: {} } }); + + await expect(server.sendLoggingMessage({ level: 'info', data: 'hello' })).rejects.toSatisfy( + (error: unknown) => error instanceof SdkError && error.code === SdkErrorCode.NotConnected + ); + }); +}); From ef39308e963496b75e50553f6265d99cacb4fbb9 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:44:22 +0100 Subject: [PATCH 16/24] fix(client): follow nextCursor until it is absent; stop on a repeated page (#2886) Co-authored-by: Claude Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/keep-next-cursor-on-early-stop.md | 5 ++ packages/client/src/client/client.ts | 27 ++++---- .../client/test/client/responseCache.test.ts | 68 ++++++++++++++++++- 3 files changed, 83 insertions(+), 17 deletions(-) create mode 100644 .changeset/keep-next-cursor-on-early-stop.md diff --git a/.changeset/keep-next-cursor-on-early-stop.md b/.changeset/keep-next-cursor-on-early-stop.md new file mode 100644 index 0000000000..a56a05d899 --- /dev/null +++ b/.changeset/keep-next-cursor-on-early-stop.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +`listTools()`, `listPrompts()`, `listResources()` and `listResourceTemplates()` called without a cursor now follow `nextCursor` until the server stops sending one, instead of stopping silently with a short list when a cursor repeats; a page that has the same items and the same `nextCursor` as the page before it ends the walk and is not added twice, and `listMaxPages` still caps the walk. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index a6665e4e15..8118b60cea 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -1614,7 +1614,7 @@ export class Client extends Protocol { } const hit = await this._serveFromCache('prompts/list', undefined, options); if (hit !== undefined) return hit; - return this._listAllPages('prompts/list', params, options, (acc, page) => acc.prompts.push(...page.prompts)); + return this._listAllPages('prompts/list', params, options, r => r.prompts); } /** @@ -1654,9 +1654,7 @@ export class Client extends Protocol { } const hit = await this._serveFromCache('resources/list', undefined, options); if (hit !== undefined) return hit; - return this._listAllPages('resources/list', params, options, (acc, page) => - acc.resources.push(...page.resources) - ); + return this._listAllPages('resources/list', params, options, r => r.resources); } /** @@ -1687,9 +1685,7 @@ export class Client extends Protocol { } const hit = await this._serveFromCache('resources/templates/list', undefined, options); if (hit !== undefined) return hit; - return this._listAllPages('resources/templates/list', params, options, (acc, page) => - acc.resourceTemplates.push(...page.resourceTemplates) - ); + return this._listAllPages('resources/templates/list', params, options, r => r.resourceTemplates); } /** @@ -1698,8 +1694,7 @@ export class Client extends Protocol { * methods' no-`cursor` auto-aggregate path. Page 1's result object is * mutated in place (its items array is extended; `nextCursor` is * cleared); page-1 metadata (`ttlMs`, `cacheScope`, `_meta`) is preserved. - * A `nextCursor` that repeats stops the walk (defence against a - * non-converging server, mcp.d's `drainList` guard); + * A page with the same items and `nextCursor` as the previous page ends the walk; * {@linkcode ClientOptions.listMaxPages} is a hard cap — hitting it * throws, so a partial aggregate is never cached. The * captured-generation guard skips the write when a `list_changed` landed @@ -1718,7 +1713,7 @@ export class Client extends Protocol { method: RequestMethod, baseParams: { readonly [key: string]: unknown } | undefined, options: CacheableRequestOptions | undefined, - append: (acc: R, page: R) => void, + items: (result: R) => unknown[], finalize?: (acc: R) => void ): Promise { // `'bypass'` is the no-touch path: the cache is neither read nor @@ -1732,9 +1727,9 @@ export class Client extends Protocol { const generation = this._cache.captureGeneration(method); const acc = (await this.request({ method, ...(baseParams && { params: { ...baseParams } }) }, options)) as R; let cursor = acc.nextCursor; - const seen = new Set(); + let previous = acc; let pages = 1; - while (cursor !== undefined && !seen.has(cursor)) { + while (cursor !== undefined) { if (this._listMaxPages !== 0 && pages >= this._listMaxPages) { throw new SdkError( SdkErrorCode.ListPaginationExceeded, @@ -1742,9 +1737,11 @@ export class Client extends Protocol { { method, listMaxPages: this._listMaxPages } ); } - seen.add(cursor); const page = (await this.request({ method, params: { ...baseParams, cursor } }, options)) as R; - append(acc, page); + // Same items and same cursor as the page before: the server made no progress. + if (page.nextCursor === cursor && JSON.stringify(items(page)) === JSON.stringify(items(previous))) break; + items(acc).push(...items(page)); + previous = page; cursor = page.nextCursor; pages++; } @@ -2536,7 +2533,7 @@ export class Client extends Protocol { 'tools/list', params, options, - (acc, page) => acc.tools.push(...page.tools), + r => r.tools, acc => this._excludeInvalidXMcpHeaderTools(acc) ); } diff --git a/packages/client/test/client/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts index cec28ac6d8..e2e2326705 100644 --- a/packages/client/test/client/responseCache.test.ts +++ b/packages/client/test/client/responseCache.test.ts @@ -326,6 +326,13 @@ interface ScriptOptions { listHint?: { ttlMs?: number; cacheScope?: 'public' | 'private' }; readHint?: { ttlMs?: number; cacheScope?: 'public' | 'private' }; serverInfo?: { name: string; version: string }; + /** + * When set, every `tools/list` page but the last carries this one `nextCursor` + * (a server that repeats a cursor). The server keeps its own position and + * serves the scripted pages in order; each response carries a `_meta` value + * that differs per response. + */ + repeatCursor?: string; } async function scriptedModernServer(pages: Tool[][], opts: ScriptOptions = {}): Promise { @@ -333,6 +340,7 @@ async function scriptedModernServer(pages: Tool[][], opts: ScriptOptions = {}): let lists = 0; const wireCounts = new Map(); const params: ({ cursor?: string; _meta?: unknown } | undefined)[] = []; + let served = 0; serverTx.onmessage = m => { const r = m as JSONRPCRequest; if (r.id === undefined) return; @@ -352,8 +360,8 @@ async function scriptedModernServer(pages: Tool[][], opts: ScriptOptions = {}): lists++; params.push(r.params as { cursor?: string; _meta?: unknown } | undefined); const cursor = (r.params as { cursor?: string } | undefined)?.cursor; - const idx = cursor === undefined ? 0 : Number(cursor); - const next = idx + 1 < pages.length ? String(idx + 1) : undefined; + const idx = opts.repeatCursor !== undefined ? served++ : cursor === undefined ? 0 : Number(cursor); + const next = idx + 1 < pages.length ? (opts.repeatCursor ?? String(idx + 1)) : undefined; void serverTx.send({ jsonrpc: '2.0', id: r.id, @@ -362,6 +370,7 @@ async function scriptedModernServer(pages: Tool[][], opts: ScriptOptions = {}): ttlMs: opts.listHint?.ttlMs ?? 0, cacheScope: opts.listHint?.cacheScope ?? 'private', tools: pages[idx] ?? [], + ...(opts.repeatCursor !== undefined && { _meta: { 'scripted/page': idx } }), ...(next !== undefined && { nextCursor: next }) } }); @@ -493,6 +502,61 @@ describe('Client response-cache substrate', () => { expect(page.tools.map(t => t.name)).toEqual(['a']); }); + it('follows a cursor that repeats until nextCursor is absent', async () => { + // The server hands out the same cursor (`''`) on every page and tracks + // its own position; the walk ends when `nextCursor` is absent (#2735). + const { clientTx, listCount } = await scriptedModernServer([[TOOL_A], [TOOL_B], [TOOL_A, TOOL_B]], { repeatCursor: '' }); + const client = modernClient(); + await client.connect(clientTx); + + const result = await client.listTools(); + expect(result.tools.map(t => t.name)).toEqual(['a', 'b', 'a', 'b']); + expect(listCount()).toBe(3); + expect(result).not.toHaveProperty('nextCursor'); + }); + + it('a page with the same items and cursor as the one before ends the walk and is not appended', async () => { + const store = new InMemoryResponseCacheStore(); + // Every response is `[a, b]` with the same `nextCursor` (`''`); only + // `_meta` differs. Page 2 makes no progress, so it ends the walk. + const { clientTx, listCount } = await scriptedModernServer( + [ + [TOOL_A, TOOL_B], + [TOOL_A, TOOL_B], + [TOOL_A, TOOL_B] + ], + { + repeatCursor: '' + } + ); + const client = modernClient(store); + await client.connect(clientTx); + + const result = await client.listTools(); + expect(result.tools.map(t => t.name)).toEqual(['a', 'b']); + expect(listCount()).toBe(2); + expect(result).not.toHaveProperty('nextCursor'); + + const entry = store.get({ method: 'tools/list', partition: part() }); + expect((JSON.parse(entry!.value) as { tools: Tool[] }).tools.map(t => t.name)).toEqual(['a', 'b']); + }); + + it('listMaxPages still ends a walk whose cursor repeats while the pages keep changing', async () => { + const { clientTx, listCount } = await scriptedModernServer([[TOOL_A], [TOOL_B], [TOOL_A], [TOOL_B], [TOOL_A]], { + repeatCursor: '' + }); + const client = new Client( + { name: 'cache-client', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: MODERN } }, listMaxPages: 3 } + ); + await client.connect(clientTx); + + const error = await client.listTools().catch(e => e as SdkError); + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.ListPaginationExceeded); + expect(listCount()).toBe(3); + }); + it('listPrompts/listResources/listResourceTemplates auto-aggregate and write the response cache', async () => { const store = new InMemoryResponseCacheStore(); const { clientTx } = await scriptedModernServer([[TOOL_A]]); From edd12e282620ebf770d67316f19cf91d4112a1bd Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:27:47 +0100 Subject: [PATCH 17/24] fix(client): deprecate omitting expectedIssuer and apply the SEP-2352 issuer check consistently (#2887) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .../expected-issuer-deprecate-omission.md | 10 + docs/clients/machine-auth.md | 13 +- docs/migration/upgrade-to-v2.md | 13 +- .../guides/clients/machine-auth.examples.ts | 9 +- examples/oauth-client-credentials/README.md | 3 +- examples/oauth-client-credentials/client.ts | 7 +- examples/oauth/dualModeAuth.ts | 16 +- packages/client/src/client/auth.ts | 37 +++- packages/client/src/client/authErrors.ts | 25 ++- .../src/client/authExtensions.examples.ts | 6 +- packages/client/src/client/authExtensions.ts | 53 ++++- packages/client/src/client/crossAppAccess.ts | 9 +- packages/client/test/client/auth.test.ts | 189 +++++++++++++++++- .../client/test/client/authExtensions.test.ts | 53 +++++ .../client/test/client/crossAppAccess.test.ts | 18 ++ .../core-internal/src/shared/authUtils.ts | 8 + .../test/shared/authUtils.test.ts | 23 ++- packages/core/src/auth.ts | 28 ++- .../src/auth/providers/proxyProvider.ts | 8 +- .../test/auth/providers/proxyProvider.test.ts | 36 ++++ test/e2e/scenarios/client-auth.test.ts | 12 +- 21 files changed, 499 insertions(+), 77 deletions(-) create mode 100644 .changeset/expected-issuer-deprecate-omission.md diff --git a/.changeset/expected-issuer-deprecate-omission.md b/.changeset/expected-issuer-deprecate-omission.md new file mode 100644 index 0000000000..8fa6d07a42 --- /dev/null +++ b/.changeset/expected-issuer-deprecate-omission.md @@ -0,0 +1,10 @@ +--- +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/core': minor +--- + +Constructing `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider` or `CrossAppAccessProvider` without `expectedIssuer` is deprecated: the constructor logs one `console.warn` and that call signature is marked `@deprecated`. Behaviour is otherwise unchanged. Pass the `issuer` of the authorization server the credentials were registered with. + +`fetchToken()` throws `AuthorizationServerMismatchError`, before sending anything, when the provider's client information is bound to a different authorization server than the one it is called with. The `AuthorizationServerMismatchError` message no longer assumes the authorization-code callback; its fields are unchanged. + +`OAuthTokensSchema` and `OAuthClientInformationSchema` accept the optional `issuer` stamp, so a provider that reads storage back through them keeps it. `auth()` overwrites it on every save. diff --git a/docs/clients/machine-auth.md b/docs/clients/machine-auth.md index 1b3c6614bb..d3c7d2caa5 100644 --- a/docs/clients/machine-auth.md +++ b/docs/clients/machine-auth.md @@ -15,7 +15,8 @@ import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from const authProvider = new ClientCredentialsProvider({ clientId: 'reporting-job', - clientSecret: 'reporting-job-secret' + clientSecret: 'reporting-job-secret', + expectedIssuer: 'https://auth.example.com' }); const client = new Client({ name: 'reporting-job', version: '1.0.0' }); @@ -26,9 +27,7 @@ await client.connect(transport); `connect` discovers the server's authorization server, posts the grant to its token endpoint, and attaches the access token to every request. On a 401 the provider refreshes the token and the transport retries once. No browser, no end user. -::: tip -Pass `expectedIssuer` to pin the credential to the authorization server it was registered with. If discovery resolves a different issuer, the SDK throws `AuthorizationServerMismatchError` instead of sending the secret. -::: +`expectedIssuer` is the `issuer` of the authorization server the credential was registered with. If discovery resolves a different issuer, the SDK throws `AuthorizationServerMismatchError` instead of sending the secret. `PrivateKeyJwtProvider` and `CrossAppAccessProvider` below take it too; omitting it is deprecated, and the credential then goes to whichever authorization server the MCP server advertises. ## Bring your own bearer token @@ -50,7 +49,8 @@ The transport calls `token()` before every request and sets the `Authorization` const authProvider = new PrivateKeyJwtProvider({ clientId: 'reporting-job', privateKey: pemEncodedKey, - algorithm: 'RS256' + algorithm: 'RS256', + expectedIssuer: 'https://auth.example.com' }); const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); @@ -80,7 +80,8 @@ const authProvider = new CrossAppAccessProvider({ return grant.jwtAuthGrant; }, clientId: 'reporting-job', - clientSecret: 'reporting-job-secret' + clientSecret: 'reporting-job-secret', + expectedIssuer: 'https://auth.example.com' }); const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 5b0310b762..b7bacd6adf 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -1155,6 +1155,7 @@ path will not catch them): | RFC 9207 `iss` mismatch / RFC 8414 §3.3 issuer-echo mismatch | `IssuerMismatchError` (`kind`, `expected`, `received`) | | Transport 403 `insufficient_scope` with `onInsufficientScope: 'throw'`, or default mode without an `OAuthClientProvider` | `InsufficientScopeError` (`requiredScope`, `resourceMetadataUrl`, `errorDescription`) | | `auth()` callback leg: discovery resolves a different AS than the recorded redirect target | `AuthorizationServerMismatchError` (`recordedIssuer`, `currentIssuer`) | +| `auth()` on a provider that cannot re-register, or `fetchToken()`: client information stamped for a different AS | `AuthorizationServerMismatchError` (`recordedIssuer`, `currentIssuer`) | #### Connect-time OAuth retry (`UnauthorizedError`) @@ -1268,7 +1269,8 @@ same handling as the POST send path. `auth()` stamps an `issuer` field onto every value it passes to `saveTokens()` / `saveClientInformation()` and threads `{ issuer }` as the `ctx` argument to those methods plus `tokens()` / `clientInformation()`. On read, a stored value whose `issuer` -names a different AS is treated as `undefined` and the flow re-registers / re-authorizes. +names a different AS is treated as `undefined` and the flow re-registers / re-authorizes +(or throws `AuthorizationServerMismatchError` when the provider has no `saveClientInformation()`). **Round-trip the stored object verbatim and you're protected** — single-slot storage works. Dropping the stamp is easy to miss: a `saveTokens()` implementation that rebuilds the object field-by-field and drops `issuer` leaves the value unstamped — @@ -1278,14 +1280,15 @@ re-stamps on first use where the provider can persist it). If you see that warni repeating after upgrading, check this first. To hold credentials for several authorization servers at once, key your storage on `ctx.issuer` (treat **`ctx === undefined` as "return the most-recently-saved token set"** — the transport's per-request `Authorization: Bearer` read calls `tokens()` with -no `ctx`). New TypeScript-only aliases `StoredOAuthTokens` / `StoredOAuthClientInformation` -add an optional `issuer?: string` field on top of the wire types. +no `ctx`). `OAuthTokensSchema` / `OAuthClientInformationSchema` keep the optional `issuer`, so +reading storage back through them is fine; the `StoredOAuthTokens` / `StoredOAuthClientInformation` +aliases name the stored shape. `OAuthClientProvider.saveAuthorizationServerUrl()` / `authorizationServerUrl()` are `@deprecated` (still written for back-compat, never read by the SDK). The bundled `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider`, and -`CrossAppAccessProvider` gain `expectedIssuer?: string` and no longer define -`saveClientInformation()`. Implement `discoveryState()` / `saveDiscoveryState()` so the +`CrossAppAccessProvider` gain `expectedIssuer?: string` (omitting it is deprecated) and no +longer define `saveClientInformation()`. Implement `discoveryState()` / `saveDiscoveryState()` so the callback leg can verify it is exchanging the code at the same AS the redirect targeted; without it the SDK `console.warn`s once per callback (`discoveryState` must persist with the same durability as `codeVerifier`). Both methods are optional on diff --git a/examples/guides/clients/machine-auth.examples.ts b/examples/guides/clients/machine-auth.examples.ts index 373c5bdc04..4eff63198c 100644 --- a/examples/guides/clients/machine-auth.examples.ts +++ b/examples/guides/clients/machine-auth.examples.ts @@ -23,7 +23,8 @@ import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from const authProvider = new ClientCredentialsProvider({ clientId: 'reporting-job', - clientSecret: 'reporting-job-secret' + clientSecret: 'reporting-job-secret', + expectedIssuer: 'https://auth.example.com' }); const client = new Client({ name: 'reporting-job', version: '1.0.0' }); @@ -48,7 +49,8 @@ function privateKeyJwt_provider(pemEncodedKey: string) { const authProvider = new PrivateKeyJwtProvider({ clientId: 'reporting-job', privateKey: pemEncodedKey, - algorithm: 'RS256' + algorithm: 'RS256', + expectedIssuer: 'https://auth.example.com' }); const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); @@ -76,7 +78,8 @@ function crossAppAccess_provider(getIdToken: () => Promise) { return grant.jwtAuthGrant; }, clientId: 'reporting-job', - clientSecret: 'reporting-job-secret' + clientSecret: 'reporting-job-secret', + expectedIssuer: 'https://auth.example.com' }); const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); diff --git a/examples/oauth-client-credentials/README.md b/examples/oauth-client-credentials/README.md index 37bd1525a9..d8fdf23ab2 100644 --- a/examples/oauth-client-credentials/README.md +++ b/examples/oauth-client-credentials/README.md @@ -34,7 +34,8 @@ import { PrivateKeyJwtProvider } from '@modelcontextprotocol/client'; const authProvider = new PrivateKeyJwtProvider({ clientId: 'my-service', privateKey: pemEncodedKey, - algorithm: 'RS256' + algorithm: 'RS256', + expectedIssuer: 'https://auth.example.com' }); ``` diff --git a/examples/oauth-client-credentials/client.ts b/examples/oauth-client-credentials/client.ts index 3d40654fac..fa14f6df28 100644 --- a/examples/oauth-client-credentials/client.ts +++ b/examples/oauth-client-credentials/client.ts @@ -10,7 +10,8 @@ * granted scopes. * * No browser, no readline. The SDK's auth driver does the discovery; the only - * thing the caller supplies is the pre-registered client's id+secret. + * thing the caller supplies is the pre-registered client's id+secret and the + * issuer of the AS it is registered with. */ import { check, parseExampleArgs } from '@mcp-examples/shared'; import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; @@ -28,9 +29,13 @@ check.match(unauth.headers.get('www-authenticate') ?? '', /Bearer/); check.match(unauth.headers.get('www-authenticate') ?? '', /oauth-protected-resource/); // Authenticated via client_credentials → 200, ctx.authInfo carries the granted scopes. +// `expectedIssuer` names the AS the client is registered with (the story's AS listens on PORT+1). +const authServerUrl = new URL('/', url); +authServerUrl.port = String(Number(authServerUrl.port) + 1); const provider = new ClientCredentialsProvider({ clientId: 'demo-m2m-client', clientSecret: 'demo-m2m-secret', + expectedIssuer: authServerUrl.href, scope: 'mcp:tools mcp:read' }); const client = new Client( diff --git a/examples/oauth/dualModeAuth.ts b/examples/oauth/dualModeAuth.ts index ac4dcc1e82..28aff03b50 100644 --- a/examples/oauth/dualModeAuth.ts +++ b/examples/oauth/dualModeAuth.ts @@ -55,11 +55,16 @@ function createHostManagedTransport(serverUrl: URL, session: HostSessionStore, u // --- MODE B: User-configured OAuth ----------------------------------------- -function createUserConfiguredTransport(serverUrl: URL, clientId: string, clientSecret: string): StreamableHTTPClientTransport { +function createUserConfiguredTransport( + serverUrl: URL, + clientId: string, + clientSecret: string, + expectedIssuer: string +): StreamableHTTPClientTransport { // Built-in OAuth provider — the transport adapts it to AuthProvider internally. // On 401, adaptOAuthProvider synthesizes onUnauthorized → handleOAuthUnauthorized, // which runs token refresh (or redirect for interactive flows). - const authProvider = new ClientCredentialsProvider({ clientId, clientSecret }); + const authProvider = new ClientCredentialsProvider({ clientId, clientSecret, expectedIssuer }); return new StreamableHTTPClientTransport(serverUrl, { authProvider }); } @@ -92,11 +97,12 @@ async function main() { } else if (mode === 'oauth') { const clientId = process.env.OAUTH_CLIENT_ID; const clientSecret = process.env.OAUTH_CLIENT_SECRET; - if (!clientId || !clientSecret) { - console.error('OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET required for oauth mode'); + const expectedIssuer = process.env.OAUTH_EXPECTED_ISSUER; + if (!clientId || !clientSecret || !expectedIssuer) { + console.error('OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET and OAUTH_EXPECTED_ISSUER required for oauth mode'); process.exit(1); } - transport = createUserConfiguredTransport(serverUrl, clientId, clientSecret); + transport = createUserConfiguredTransport(serverUrl, clientId, clientSecret, expectedIssuer); } else { console.error(`Unknown mode: ${mode}. Use 'host' or 'oauth'.`); process.exit(1); diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index a3fa18f054..7bd8930a49 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -25,7 +25,8 @@ import { OAuthTokensSchema, OpenIdProviderDiscoveryMetadataSchema, resourceUrlFromServerUrl, - stampErrorBrands + stampErrorBrands, + withoutIssuer } from '@modelcontextprotocol/core-internal'; import pkceChallenge from 'pkce-challenge'; @@ -129,12 +130,14 @@ export interface OAuthClientInformationContext { * "binding on first use" claim would be false and would fire on every call. */ export function discardIfIssuerMismatch( - stored: T | undefined, + stored: T | null | undefined, issuer: string, opts?: { canPersistStamp?: boolean } ): T | undefined { - if (stored === undefined) return undefined; - if (stored.issuer === undefined) { + // Nothing stored (`null` from a `JSON.parse(storage.getItem(...))`-style getter included). + if (!stored) return undefined; + // A stamp that is not a string (raw storage) counts as no stamp. + if (typeof stored.issuer !== 'string') { if (opts?.canPersistStamp !== false) { console.warn( `[mcp-sdk] SEP-2352: stored OAuth credential has no 'issuer' stamp (pre-upgrade storage or ` + @@ -142,7 +145,7 @@ export function discardIfIssuerMismatch( `ensure your provider round-trips the issuer field.` ); } - return stored; + return stored.issuer === undefined ? stored : { ...stored, issuer: undefined }; } return issuersMatch(stored.issuer, issuer) ? stored : undefined; } @@ -1001,7 +1004,8 @@ export interface AuthOptions { /** * Opt-out for the RFC 8414 §3.3 issuer-echo check during authorization * server discovery. Disabling it is **security-weakening** and intended only - * for authorization servers known to publish a mismatched `issuer`. + * for authorization servers known to publish a mismatched `issuer`. The unchecked + * `issuer` is also what `expectedIssuer` and stored `issuer` stamps are compared with. * * @default false */ @@ -2254,7 +2258,7 @@ export async function executeTokenRequest( const json: unknown = await response.json(); try { - return OAuthTokensSchema.parse(json); + return OAuthTokensSchema.parse(withoutIssuer(json)); } catch (parseError) { // Some OAuth servers (e.g., GitHub) return error responses with HTTP 200 status. // Check for error field only if token parsing failed. @@ -2441,6 +2445,18 @@ export async function fetchToken( }); } + // SEP-2352: nothing is sent to an authorization server other than the one the client information is stamped for. + const issuer = metadata?.issuer ?? String(authorizationServerUrl); + const readClientInformation = async () => { + const rawClientInfo = await provider.clientInformation({ issuer }); + const checked = discardIfIssuerMismatch(rawClientInfo, issuer, { canPersistStamp: false }); + if (rawClientInfo && checked === undefined) { + throw new AuthorizationServerMismatchError(String(rawClientInfo.issuer), issuer); + } + return checked; + }; + let clientInformation = await readClientInformation(); + // Prefer scope from options, fallback to provider.clientMetadata.scope const effectiveScope = scope ?? provider.clientMetadata.scope; @@ -2462,12 +2478,13 @@ export async function fetchToken( tokenRequestParams = prepareAuthorizationCodeRequest(authorizationCode, codeVerifier, provider.redirectUrl); } - const clientInformation = await provider.clientInformation({ issuer: metadata?.issuer ?? String(authorizationServerUrl) }); + // A provider may fill in its client information while the request is prepared. + clientInformation ??= await readClientInformation(); return executeTokenRequest(authorizationServerUrl, { metadata, tokenRequestParams, - clientInformation: clientInformation ?? undefined, + clientInformation, addClientAuthentication: provider.addClientAuthentication, resource, dpop: await provider.dpop?.(), @@ -2542,5 +2559,5 @@ export async function registerClient( throw new RegistrationRejectedError({ status: response.status, body: await response.text(), submittedMetadata }); } - return OAuthClientInformationFullSchema.parse(await response.json()); + return OAuthClientInformationFullSchema.parse(withoutIssuer(await response.json())); } diff --git a/packages/client/src/client/authErrors.ts b/packages/client/src/client/authErrors.ts index 98b2c57c32..df61820c5f 100644 --- a/packages/client/src/client/authErrors.ts +++ b/packages/client/src/client/authErrors.ts @@ -177,14 +177,18 @@ export class InsecureTokenEndpointError extends OAuthClientFlowError { * `requiredScope`, which appears in the error message). */ /** - * Thrown by `auth()` on the authorization-code callback leg when the - * authorization server resolved by discovery differs from the one recorded in - * `discoveryState()` at redirect time. The `authorization_code` and PKCE - * `code_verifier` are bound to the AS that minted the code (RFC 7636); sending - * them to a different AS's token endpoint is a credential-exfiltration vector. + * Thrown by `auth()` and `fetchToken()` when the authorization server in use differs + * from the one a credential is bound to and the flow cannot simply start over: * - * This is the only runtime check left in the SEP-2352 model — stored tokens and - * client credentials are protected structurally by the `issuer` stamp instead. + * - on the authorization-code callback leg, when it differs from the one recorded + * in `discoveryState()` at redirect time — the `authorization_code` and PKCE + * `code_verifier` are bound to the AS that minted the code (RFC 7636); + * - for client information whose `issuer` stamp names a different authorization + * server on a provider that cannot re-register (no `saveClientInformation()`, + * e.g. the bundled static-credential providers); `fetchToken()` throws for any provider. + * + * Stored tokens and re-registrable client information are protected structurally + * by the `issuer` stamp instead (they read back as absent). */ export class AuthorizationServerMismatchError extends OAuthClientFlowError { static { @@ -192,15 +196,14 @@ export class AuthorizationServerMismatchError extends OAuthClientFlowError { } constructor( - /** The issuer recorded in `discoveryState()` when the authorization redirect was issued. */ + /** The issuer the credential is bound to (recorded in `discoveryState()` at redirect time, or stamped on the client information). */ public readonly recordedIssuer: string, /** The issuer resolved by discovery on this call. */ public readonly currentIssuer: string ) { super( - `Authorization server changed between redirect and callback ` + - `(redirected to ${JSON.stringify(recordedIssuer)}, callback resolved ${JSON.stringify(currentIssuer)}); ` + - `refusing to send authorization_code/code_verifier to a different token endpoint` + `Authorization server mismatch: credentials are bound to ${JSON.stringify(recordedIssuer)} ` + + `but this call resolved ${JSON.stringify(currentIssuer)}; refusing to present them to a different authorization server` ); } } diff --git a/packages/client/src/client/authExtensions.examples.ts b/packages/client/src/client/authExtensions.examples.ts index 668cdd3504..35bee44d5e 100644 --- a/packages/client/src/client/authExtensions.examples.ts +++ b/packages/client/src/client/authExtensions.examples.ts @@ -33,7 +33,8 @@ function ClientCredentialsProvider_basicUsage(serverUrl: URL) { //#region ClientCredentialsProvider_basicUsage const provider = new ClientCredentialsProvider({ clientId: 'my-client', - clientSecret: 'my-secret' + clientSecret: 'my-secret', + expectedIssuer: 'https://auth.example.com' }); const transport = new StreamableHTTPClientTransport(serverUrl, { @@ -51,7 +52,8 @@ function PrivateKeyJwtProvider_basicUsage(pemEncodedPrivateKey: string, serverUr const provider = new PrivateKeyJwtProvider({ clientId: 'my-client', privateKey: pemEncodedPrivateKey, - algorithm: 'RS256' + algorithm: 'RS256', + expectedIssuer: 'https://auth.example.com' }); const transport = new StreamableHTTPClientTransport(serverUrl, { diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index 073b42ee0c..e210786b7e 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -94,6 +94,23 @@ export function createPrivateKeyJwtAuth(options: { }; } +/** + * The `issuer` stamp for constructor-supplied client information. Omitting `expectedIssuer` is + * deprecated: nothing else says which authorization server such credentials belong to. + */ +function checkedExpectedIssuer(expectedIssuer: string | undefined): string | undefined { + if (expectedIssuer === undefined) { + console.warn( + '[mcp-sdk] Omitting `expectedIssuer` is deprecated. Without it, the MCP server decides which authorization ' + + "server receives this client's credentials; pass your authorization server's issuer URL as " + + '`expectedIssuer` so they are only sent there.' + ); + } else if (typeof expectedIssuer !== 'string' || !expectedIssuer) { + throw new Error("expectedIssuer must be the authorization server's issuer URL"); + } + return expectedIssuer; +} + /** * Options for creating a {@linkcode ClientCredentialsProvider}. */ @@ -121,9 +138,10 @@ export interface ClientCredentialsProviderOptions { /** * The authorization server's `issuer` identifier these credentials were registered with. * Stamped onto the stored client information so `auth()`'s SEP-2352 issuer check - * refuses to send the credential to any other authorization server. Hosts supplying - * static client credentials SHOULD set this; omitting it preserves the legacy - * (no-binding) behaviour for back-compat. + * refuses to send the credential to any other authorization server. + * + * Omitting it is deprecated: the credential then goes to whichever authorization server + * the MCP server advertises. */ expectedIssuer?: string; } @@ -138,7 +156,8 @@ export interface ClientCredentialsProviderOptions { * ```ts source="./authExtensions.examples.ts#ClientCredentialsProvider_basicUsage" * const provider = new ClientCredentialsProvider({ * clientId: 'my-client', - * clientSecret: 'my-secret' + * clientSecret: 'my-secret', + * expectedIssuer: 'https://auth.example.com' * }); * * const transport = new StreamableHTTPClientTransport(serverUrl, { @@ -151,11 +170,14 @@ export class ClientCredentialsProvider implements OAuthClientProvider { private _clientInfo: StoredOAuthClientInformation; private _clientMetadata: OAuthClientMetadata; + constructor(options: ClientCredentialsProviderOptions & { expectedIssuer: string }); + /** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */ + constructor(options: ClientCredentialsProviderOptions); constructor(options: ClientCredentialsProviderOptions) { this._clientInfo = { client_id: options.clientId, client_secret: options.clientSecret, - issuer: options.expectedIssuer + issuer: checkedExpectedIssuer(options.expectedIssuer) }; this._clientMetadata = { client_name: options.clientName ?? 'client-credentials-client', @@ -274,7 +296,8 @@ export interface PrivateKeyJwtProviderOptions { * const provider = new PrivateKeyJwtProvider({ * clientId: 'my-client', * privateKey: pemEncodedPrivateKey, - * algorithm: 'RS256' + * algorithm: 'RS256', + * expectedIssuer: 'https://auth.example.com' * }); * * const transport = new StreamableHTTPClientTransport(serverUrl, { @@ -288,10 +311,13 @@ export class PrivateKeyJwtProvider implements OAuthClientProvider { private _clientMetadata: OAuthClientMetadata; addClientAuthentication: AddClientAuthentication; + constructor(options: PrivateKeyJwtProviderOptions & { expectedIssuer: string }); + /** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */ + constructor(options: PrivateKeyJwtProviderOptions); constructor(options: PrivateKeyJwtProviderOptions) { this._clientInfo = { client_id: options.clientId, - issuer: options.expectedIssuer + issuer: checkedExpectedIssuer(options.expectedIssuer) }; this._clientMetadata = { client_name: options.clientName ?? 'private-key-jwt-client', @@ -400,10 +426,13 @@ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider { private _clientMetadata: OAuthClientMetadata; addClientAuthentication: AddClientAuthentication; + constructor(options: StaticPrivateKeyJwtProviderOptions & { expectedIssuer: string }); + /** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */ + constructor(options: StaticPrivateKeyJwtProviderOptions); constructor(options: StaticPrivateKeyJwtProviderOptions) { this._clientInfo = { client_id: options.clientId, - issuer: options.expectedIssuer + issuer: checkedExpectedIssuer(options.expectedIssuer) }; this._clientMetadata = { client_name: options.clientName ?? 'static-private-key-jwt-client', @@ -591,7 +620,8 @@ export interface CrossAppAccessProviderOptions { * return result.jwtAuthGrant; * }, * clientId: 'my-mcp-client', - * clientSecret: 'my-mcp-secret' + * clientSecret: 'my-mcp-secret', + * expectedIssuer: 'https://auth.example.com' * }); * * const transport = new StreamableHTTPClientTransport(serverUrl, { @@ -609,11 +639,14 @@ export class CrossAppAccessProvider implements OAuthClientProvider { private _resourceUrl?: string; private _scope?: string; + constructor(options: CrossAppAccessProviderOptions & { expectedIssuer: string }); + /** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */ + constructor(options: CrossAppAccessProviderOptions); constructor(options: CrossAppAccessProviderOptions) { this._clientInfo = { client_id: options.clientId, client_secret: options.clientSecret, - issuer: options.expectedIssuer + issuer: checkedExpectedIssuer(options.expectedIssuer) }; this._clientMetadata = { client_name: options.clientName ?? 'cross-app-access-client', diff --git a/packages/client/src/client/crossAppAccess.ts b/packages/client/src/client/crossAppAccess.ts index 49f010d626..a5491258ec 100644 --- a/packages/client/src/client/crossAppAccess.ts +++ b/packages/client/src/client/crossAppAccess.ts @@ -9,7 +9,12 @@ */ import type { FetchLike } from '@modelcontextprotocol/core-internal'; -import { IdJagTokenExchangeResponseSchema, OAuthErrorResponseSchema, OAuthTokensSchema } from '@modelcontextprotocol/core-internal'; +import { + IdJagTokenExchangeResponseSchema, + OAuthErrorResponseSchema, + OAuthTokensSchema, + withoutIssuer +} from '@modelcontextprotocol/core-internal'; import type { ClientAuthMethod } from './auth'; import { applyClientAuthentication, assertSecureTokenEndpoint, discoverAuthorizationServerMetadata } from './auth'; @@ -298,7 +303,7 @@ export async function exchangeJwtAuthGrant(options: { const responseBody = await response.json(); // Validate response using core schema - const parseResult = OAuthTokensSchema.safeParse(responseBody); + const parseResult = OAuthTokensSchema.safeParse(withoutIssuer(responseBody)); if (!parseResult.success) { throw new Error(`Invalid token response: ${parseResult.error.message}`); } diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index e57ab4e91c..a87cbd7d92 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -6,7 +6,13 @@ import type { StoredOAuthClientInformation, StoredOAuthTokens } from '@modelcontextprotocol/core-internal'; -import { LATEST_PROTOCOL_VERSION, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/core-internal'; +import { + LATEST_PROTOCOL_VERSION, + OAuthClientInformationSchema, + OAuthError, + OAuthErrorCode, + OAuthTokensSchema +} from '@modelcontextprotocol/core-internal'; import type { Mock } from 'vitest'; import { expect, vi } from 'vitest'; @@ -25,6 +31,7 @@ import { discoverOAuthServerInfo, exchangeAuthorization, extractWWWAuthenticateParams, + fetchToken, InsecureTokenEndpointError, isHttpsUrl, isStrictScopeSuperset, @@ -5004,7 +5011,7 @@ describe('OAuth Authorization', () => { function createMigratingFetch() { let active = AS_ONE; const registerCalls: string[] = []; - const tokenCalls: Array<{ issuer: string; body: URLSearchParams }> = []; + const tokenCalls: Array<{ issuer: string; body: URLSearchParams; authorization: string | null }> = []; const fetchFn = async (url: string | URL, init?: RequestInit): Promise => { const u = new URL(String(url)); if (u.pathname.includes('/.well-known/oauth-protected-resource')) { @@ -5019,7 +5026,7 @@ describe('OAuth Authorization', () => { } if (u.pathname === '/token') { const body = new URLSearchParams(String(init?.body)); - tokenCalls.push({ issuer: u.origin, body }); + tokenCalls.push({ issuer: u.origin, body, authorization: new Headers(init?.headers).get('authorization') }); return Response.json({ access_token: 'at', token_type: 'Bearer' }); } return new Response(null, { status: 404 }); @@ -5310,8 +5317,8 @@ describe('OAuth Authorization', () => { it('ClientCredentialsProvider without expectedIssuer: no SEP-2352 warn on auth()', async () => { const srv = createMigratingFetch(); - const provider = new ClientCredentialsProvider({ clientId: 'static', clientSecret: 's' }); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const provider = new ClientCredentialsProvider({ clientId: 'static', clientSecret: 's' }); await auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn }); await auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn }); @@ -5319,6 +5326,180 @@ describe('OAuth Authorization', () => { warn.mockRestore(); }); + it('fetchToken sends nothing to an authorization server other than the one the client information is bound to', async () => { + const srv = createMigratingFetch(); + const addClientAuthentication = vi.fn>(); + const prepareTokenRequest = vi.fn(() => new URLSearchParams({ grant_type: 'client_credentials' })); + const provider: OAuthClientProvider = { ...createBlobProvider(false), addClientAuthentication, prepareTokenRequest }; + provider.clientInformation = () => ({ client_id: 'cid', client_secret: 'bound-secret', issuer: AS_ONE }); + + const err = await fetchToken(provider, AS_TWO, { metadata: asMetadata(AS_TWO), fetchFn: srv.fetchFn }).then( + () => undefined, + e => e + ); + expect(err).toBeInstanceOf(AuthorizationServerMismatchError); + expect(err).toMatchObject({ recordedIssuer: AS_ONE, currentIssuer: AS_TWO }); + expect(srv.tokenCalls).toEqual([]); + expect(prepareTokenRequest).not.toHaveBeenCalled(); + expect(addClientAuthentication).not.toHaveBeenCalled(); + + // A matching stamp is used as before. + provider.addClientAuthentication = undefined; + await fetchToken(provider, AS_ONE, { metadata: asMetadata(AS_ONE), fetchFn: srv.fetchFn }); + expect(srv.tokenCalls.map(c => [c.issuer, c.authorization])).toEqual([[AS_ONE, `Basic ${btoa('cid:bound-secret')}`]]); + }); + + it('a provider that reads storage back through the SDK schemas keeps the binding', async () => { + const storage = new Map(); + const provider: OAuthClientProvider = { + ...createBlobProvider(false), + clientInformation: () => + storage.has('info') ? OAuthClientInformationSchema.parseAsync(JSON.parse(storage.get('info')!)) : undefined, + saveClientInformation: i => void storage.set('info', JSON.stringify(i)), + tokens: () => (storage.has('tokens') ? OAuthTokensSchema.parseAsync(JSON.parse(storage.get('tokens')!)) : undefined), + saveTokens: t => void storage.set('tokens', JSON.stringify(t)) + }; + storage.set('info', JSON.stringify({ client_id: 'cid', client_secret: 'secret-one', issuer: AS_ONE })); + storage.set('tokens', JSON.stringify({ access_token: 'at', token_type: 'Bearer', refresh_token: 'rt-one', issuer: AS_ONE })); + const srv = createMigratingFetch(); + srv.switchTo(AS_TWO); + + expect(await auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn })).toBe('REDIRECT'); + expect(srv.tokenCalls).toEqual([]); + expect(OAuthTokensSchema.parse({ access_token: 'at', token_type: 'Bearer', issuer: null }).issuer).toBeUndefined(); + }); + + it('a null clientInformation() counts as no client information', async () => { + // The `JSON.parse(storage.getItem(key))` idiom yields `null`, not `undefined`, for an empty slot. + const storage = new Map(); + const srv = createMigratingFetch(); + const provider: OAuthClientProvider = { + get redirectUrl() { + return undefined; + }, + get clientMetadata() { + return { client_name: 't', redirect_uris: [] }; + }, + clientInformation: () => JSON.parse(storage.get('info') ?? 'null'), + tokens: () => undefined, + saveTokens: () => {}, + redirectToAuthorization: () => {}, + saveCodeVerifier: () => {}, + codeVerifier: () => 'v', + prepareTokenRequest: () => new URLSearchParams({ grant_type: 'client_credentials' }) + }; + + await fetchToken(provider, AS_ONE, { metadata: asMetadata(AS_ONE), fetchFn: srv.fetchFn }); + expect(srv.tokenCalls).toHaveLength(1); + expect(srv.tokenCalls[0]!.authorization).toBeNull(); + expect(srv.tokenCalls[0]!.body.get('client_id')).toBeNull(); + + await expect(auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn })).rejects.toThrow( + 'OAuth client information must be saveable for dynamic registration' + ); + }); + + /** Adds an `issuer` to every token and registration response of `fetchFn`. */ + const withResponseIssuer = + (fetchFn: ReturnType['fetchFn']) => + async (url: string | URL, init?: RequestInit): Promise => { + const response = await fetchFn(url, init); + if (!['/token', '/register'].includes(new URL(String(url)).pathname)) return response; + const body = { ...((await response.json()) as object), issuer: 'https://other.example.com' }; + return Response.json(body, { status: response.status }); + }; + + it('an issuer in a token or registration response is not returned', async () => { + const fetchFn = withResponseIssuer(createMigratingFetch().fetchFn); + const metadata = asMetadata(AS_ONE); + const clientInformation = { client_id: 'cid' }; + const provider: OAuthClientProvider = { + ...createBlobProvider(false), + clientInformation: () => clientInformation, + prepareTokenRequest: () => new URLSearchParams({ grant_type: 'client_credentials' }) + }; + + const results = [ + await registerClient(AS_ONE, { metadata, clientMetadata: { redirect_uris: [] }, fetchFn }), + await exchangeAuthorization(AS_ONE, { + metadata, + clientInformation, + authorizationCode: 'code', + codeVerifier: 'v', + redirectUri: 'http://localhost:3000/callback', + fetchFn + }), + await refreshAuthorization(AS_ONE, { metadata, clientInformation, refreshToken: 'rt', fetchFn }), + await fetchToken(provider, AS_ONE, { metadata, fetchFn }) + ]; + for (const result of results) { + expect(result).not.toHaveProperty('issuer'); + } + }); + + it('auth() saves the resolved issuer when the responses carry another one', async () => { + const srv = createMigratingFetch(); + const provider = { + ...createBlobProvider(false), + redirectUrl: undefined, + prepareTokenRequest: () => new URLSearchParams({ grant_type: 'client_credentials' }) + }; + + const fetchFn = withResponseIssuer(srv.fetchFn); + expect(await auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn })).toBe('AUTHORIZED'); + expect(srv.registerCalls).toEqual([AS_ONE]); + expect(srv.tokenCalls).toHaveLength(1); + expect(provider.stored.info?.issuer).toBe(AS_ONE); + expect(provider.stored.tokens?.issuer).toBe(AS_ONE); + }); + + it('fetchToken uses client information that the provider fills in while preparing the request', async () => { + const srv = createMigratingFetch(); + let filled: StoredOAuthClientInformation | undefined; + const provider: OAuthClientProvider = { + ...createBlobProvider(false), + clientInformation: () => filled, + prepareTokenRequest: () => { + filled = { client_id: 'cid', client_secret: 'lazy-secret', issuer: AS_ONE }; + return new URLSearchParams({ grant_type: 'client_credentials' }); + } + }; + + await fetchToken(provider, AS_ONE, { metadata: asMetadata(AS_ONE), fetchFn: srv.fetchFn }); + expect(srv.tokenCalls.map(c => [c.issuer, c.authorization])).toEqual([[AS_ONE, `Basic ${btoa('cid:lazy-secret')}`]]); + + // The value read after preparing goes through the same check. + filled = undefined; + const err = await fetchToken(provider, AS_TWO, { metadata: asMetadata(AS_TWO), fetchFn: srv.fetchFn }).then( + () => undefined, + e => e + ); + expect(err).toBeInstanceOf(AuthorizationServerMismatchError); + expect(err).toMatchObject({ recordedIssuer: AS_ONE, currentIssuer: AS_TWO }); + expect(srv.tokenCalls).toHaveLength(1); + }); + + it.each([null, 42, { href: AS_ONE }])('a stored issuer of %j counts as no stamp', async issuer => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const raw = { client_id: 'cid', client_secret: 's', issuer } as unknown as StoredOAuthClientInformation; + expect(discardIfIssuerMismatch(raw, AS_ONE)).toEqual({ client_id: 'cid', client_secret: 's' }); + expect(warn).toHaveBeenCalledTimes(1); + + const srv = createMigratingFetch(); + const provider = { ...createBlobProvider(false), prepareTokenRequest: () => new URLSearchParams({ grant_type: 'x' }) }; + provider.stored.info = raw; + provider.stored.tokens = { access_token: 'at', token_type: 'Bearer', issuer } as unknown as StoredOAuthTokens; + + await fetchToken(provider, AS_ONE, { metadata: asMetadata(AS_ONE), fetchFn: srv.fetchFn }); + expect(srv.tokenCalls.map(c => c.authorization)).toEqual([`Basic ${btoa('cid:s')}`]); + + // auth() binds it on first use, as it does for a value with no stamp. + expect(await auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn })).toBe('REDIRECT'); + expect(provider.stored.info?.issuer).toBe(AS_ONE); + expect(provider.stored.tokens?.issuer).toBe(AS_ONE); + warn.mockRestore(); + }); + it('m2m expectedIssuer: ClientCredentialsProvider refuses to send the credential to a different AS', async () => { const srv = createMigratingFetch(); srv.switchTo(AS_TWO); diff --git a/packages/client/test/client/authExtensions.test.ts b/packages/client/test/client/authExtensions.test.ts index 3f55593fc7..cb4f36a1e1 100644 --- a/packages/client/test/client/authExtensions.test.ts +++ b/packages/client/test/client/authExtensions.test.ts @@ -1,6 +1,7 @@ import { createMockOAuthFetch } from '@modelcontextprotocol/test-helpers'; import { describe, expect, it, vi } from 'vitest'; +import type { OAuthClientProvider } from '../../src/client/auth'; import { auth } from '../../src/client/auth'; import { ClientCredentialsProvider, @@ -16,6 +17,7 @@ const AUTH_SERVER_URL = 'https://auth.example.com'; describe('auth-extensions providers (end-to-end with auth())', () => { it('authenticates using ClientCredentialsProvider with client_secret_basic', async () => { const provider = new ClientCredentialsProvider({ + expectedIssuer: AUTH_SERVER_URL, clientId: 'my-client', clientSecret: 'my-secret', clientName: 'test-client' @@ -53,6 +55,7 @@ describe('auth-extensions providers (end-to-end with auth())', () => { it('sends scope in token request when ClientCredentialsProvider is configured with scope', async () => { const provider = new ClientCredentialsProvider({ + expectedIssuer: AUTH_SERVER_URL, clientId: 'my-client', clientSecret: 'my-secret', clientName: 'test-client', @@ -82,6 +85,7 @@ describe('auth-extensions providers (end-to-end with auth())', () => { it('authenticates using PrivateKeyJwtProvider with private_key_jwt', async () => { const provider = new PrivateKeyJwtProvider({ + expectedIssuer: AUTH_SERVER_URL, clientId: 'client-id', privateKey: 'a-string-secret-at-least-256-bits-long', algorithm: 'HS256', @@ -125,6 +129,7 @@ describe('auth-extensions providers (end-to-end with auth())', () => { it('sends scope in token request when PrivateKeyJwtProvider is configured with scope', async () => { const provider = new PrivateKeyJwtProvider({ + expectedIssuer: AUTH_SERVER_URL, clientId: 'client-id', privateKey: 'a-string-secret-at-least-256-bits-long', algorithm: 'HS256', @@ -157,6 +162,7 @@ describe('auth-extensions providers (end-to-end with auth())', () => { it('fails when PrivateKeyJwtProvider is configured with an unsupported algorithm', async () => { const provider = new PrivateKeyJwtProvider({ + expectedIssuer: AUTH_SERVER_URL, clientId: 'client-id', privateKey: 'a-string-secret-at-least-256-bits-long', algorithm: 'none', @@ -180,6 +186,7 @@ describe('auth-extensions providers (end-to-end with auth())', () => { const staticAssertion = 'header.payload.signature'; const provider = new StaticPrivateKeyJwtProvider({ + expectedIssuer: AUTH_SERVER_URL, clientId: 'static-client', jwtBearerAssertion: staticAssertion, clientName: 'static-private-key-jwt-client' @@ -217,6 +224,7 @@ describe('auth-extensions providers (end-to-end with auth())', () => { const staticAssertion = 'header.payload.signature'; const provider = new StaticPrivateKeyJwtProvider({ + expectedIssuer: AUTH_SERVER_URL, clientId: 'static-client', jwtBearerAssertion: staticAssertion, clientName: 'static-private-key-jwt-client', @@ -247,6 +255,35 @@ describe('auth-extensions providers (end-to-end with auth())', () => { }); }); +describe('expectedIssuer', () => { + const providers: Array<[string, (options: { expectedIssuer?: string }) => OAuthClientProvider]> = [ + ['ClientCredentialsProvider', o => new ClientCredentialsProvider({ clientId: 'c', clientSecret: 's', ...o })], + ['PrivateKeyJwtProvider', o => new PrivateKeyJwtProvider({ clientId: 'c', privateKey: 'k'.repeat(64), algorithm: 'HS256', ...o })], + ['StaticPrivateKeyJwtProvider', o => new StaticPrivateKeyJwtProvider({ clientId: 'c', jwtBearerAssertion: 'a', ...o })], + ['CrossAppAccessProvider', o => new CrossAppAccessProvider({ assertion: async () => 'g', clientId: 'c', clientSecret: 's', ...o })] + ]; + + it.each(providers)('%s logs one deprecation message at construction when it is omitted', async (_name, create) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fetchFn = createMockOAuthFetch({ resourceServerUrl: RESOURCE_SERVER_URL, authServerUrl: AUTH_SERVER_URL }); + + create({ expectedIssuer: AUTH_SERVER_URL }); + expect(warn).not.toHaveBeenCalled(); + + const provider = create({}); + expect(await auth(provider, { serverUrl: RESOURCE_SERVER_URL, fetchFn })).toBe('AUTHORIZED'); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Omitting `expectedIssuer` is deprecated')); + warn.mockRestore(); + }); + + it.each([null, '', 42, new URL(AUTH_SERVER_URL)])('rejects %j at construction', value => { + expect(() => new ClientCredentialsProvider({ clientId: 'c', clientSecret: 's', expectedIssuer: value as string })).toThrow( + 'expectedIssuer must be' + ); + }); +}); + describe('createPrivateKeyJwtAuth', () => { const baseOptions = { issuer: 'client-id', @@ -450,6 +487,7 @@ describe('createPrivateKeyJwtAuth', () => { it('passes custom claims through PrivateKeyJwtProvider', async () => { const provider = new PrivateKeyJwtProvider({ + expectedIssuer: AUTH_SERVER_URL, clientId: 'client-id', privateKey: 'a-string-secret-at-least-256-bits-long', algorithm: 'HS256', @@ -487,6 +525,7 @@ describe('CrossAppAccessProvider', () => { expect(ctx.fetchFn).toBeDefined(); return 'jwt-authorization-grant-token'; }, + expectedIssuer: AUTH_SERVER_URL, clientId: 'my-mcp-client', clientSecret: 'my-mcp-secret', clientName: 'xaa-test-client' @@ -535,6 +574,7 @@ describe('CrossAppAccessProvider', () => { capturedScope = ctx.scope; return 'jwt-grant'; }, + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -570,6 +610,7 @@ describe('CrossAppAccessProvider', () => { capturedFetchFn = ctx.fetchFn; return 'jwt-grant'; }, + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret', fetchFn: customFetch @@ -587,6 +628,7 @@ describe('CrossAppAccessProvider', () => { it('throws error when authorization server URL is not available', async () => { const provider = new CrossAppAccessProvider({ assertion: async () => 'jwt-grant', + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -600,6 +642,7 @@ describe('CrossAppAccessProvider', () => { it('throws error when resource URL is not available', async () => { const provider = new CrossAppAccessProvider({ assertion: async () => 'jwt-grant', + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -615,6 +658,7 @@ describe('CrossAppAccessProvider', () => { it('stores and retrieves authorization server URL', () => { const provider = new CrossAppAccessProvider({ assertion: async () => 'jwt-grant', + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -628,6 +672,7 @@ describe('CrossAppAccessProvider', () => { it('stores and retrieves resource URL', () => { const provider = new CrossAppAccessProvider({ assertion: async () => 'jwt-grant', + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -641,6 +686,7 @@ describe('CrossAppAccessProvider', () => { it('has correct client metadata', () => { const provider = new CrossAppAccessProvider({ assertion: async () => 'jwt-grant', + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret', clientName: 'custom-xaa-client' @@ -656,6 +702,7 @@ describe('CrossAppAccessProvider', () => { it('uses default client name when not provided', () => { const provider = new CrossAppAccessProvider({ assertion: async () => 'jwt-grant', + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -666,6 +713,7 @@ describe('CrossAppAccessProvider', () => { it('returns undefined for redirectUrl (non-interactive flow)', () => { const provider = new CrossAppAccessProvider({ assertion: async () => 'jwt-grant', + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -676,6 +724,7 @@ describe('CrossAppAccessProvider', () => { it('throws error for redirectToAuthorization (not used in jwt-bearer)', () => { const provider = new CrossAppAccessProvider({ assertion: async () => 'jwt-grant', + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -686,6 +735,7 @@ describe('CrossAppAccessProvider', () => { it('throws error for codeVerifier (not used in jwt-bearer)', () => { const provider = new CrossAppAccessProvider({ assertion: async () => 'jwt-grant', + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -698,6 +748,7 @@ describe('CrossAppAccessProvider', () => { assertion: async () => { throw new Error('Failed to get ID token from IdP'); }, + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -722,6 +773,7 @@ describe('CrossAppAccessProvider', () => { setTimeout(() => resolve('async-jwt-grant'), 10); }); }, + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -746,6 +798,7 @@ describe('CrossAppAccessProvider', () => { it('includes scope in token request params when provided', async () => { const provider = new CrossAppAccessProvider({ assertion: async () => 'jwt-grant', + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); diff --git a/packages/client/test/client/crossAppAccess.test.ts b/packages/client/test/client/crossAppAccess.test.ts index b81d3527c8..fd4752a9c5 100644 --- a/packages/client/test/client/crossAppAccess.test.ts +++ b/packages/client/test/client/crossAppAccess.test.ts @@ -458,6 +458,24 @@ describe('crossAppAccess', () => { ).rejects.toThrow('JWT grant exchange failed: invalid_grant - JWT signature verification failed'); }); + it('does not return an issuer from the token response', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ access_token: 'at', token_type: 'Bearer', issuer: 'https://other.example.com' }) + } as Response); + + const tokens = await exchangeJwtAuthGrant({ + tokenEndpoint: 'https://auth.chat.example/token', + jwtAuthGrant: 'jwt', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }); + + expect(tokens).toEqual({ access_token: 'at', token_type: 'Bearer' }); + expect(tokens).not.toHaveProperty('issuer'); + }); + it('validates token response with schema', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/packages/core-internal/src/shared/authUtils.ts b/packages/core-internal/src/shared/authUtils.ts index 3083e425b9..a73575eb73 100644 --- a/packages/core-internal/src/shared/authUtils.ts +++ b/packages/core-internal/src/shared/authUtils.ts @@ -14,6 +14,14 @@ export function resourceUrlFromServerUrl(url: URL | string): URL { return resourceURL; } +/** Returns an authorization-server response body without `issuer`, which only the client sets, when it stores a value (SEP-2352). */ +export function withoutIssuer(body: unknown): unknown { + if (typeof body !== 'object' || body === null || Array.isArray(body)) return body; + const copy: Record = { ...body }; + delete copy.issuer; + return copy; +} + /** * Checks if a requested resource URL matches a configured resource URL. * A requested resource matches if it has the same scheme, domain, port, diff --git a/packages/core-internal/test/shared/authUtils.test.ts b/packages/core-internal/test/shared/authUtils.test.ts index 312d1809ef..85a6d266b6 100644 --- a/packages/core-internal/test/shared/authUtils.test.ts +++ b/packages/core-internal/test/shared/authUtils.test.ts @@ -1,4 +1,4 @@ -import { checkResourceAllowed, resourceUrlFromServerUrl } from '../../src/shared/authUtils'; +import { checkResourceAllowed, resourceUrlFromServerUrl, withoutIssuer } from '../../src/shared/authUtils'; describe('auth-utils', () => { describe('resourceUrlFromServerUrl', () => { @@ -87,4 +87,25 @@ describe('auth-utils', () => { ).toBe(false); }); }); + + describe('withoutIssuer', () => { + it('should drop issuer and keep everything else', () => { + const body = { access_token: 'at', token_type: 'Bearer', issuer: 'https://as.example.com', extra: 1 }; + expect(withoutIssuer(body)).toEqual({ access_token: 'at', token_type: 'Bearer', extra: 1 }); + expect(withoutIssuer(body)).not.toHaveProperty('issuer'); + expect(body.issuer).toBe('https://as.example.com'); + }); + + it('should drop an issuer that is not an own property', () => { + expect((withoutIssuer(Object.create({ issuer: 'https://as.example.com' })) as { issuer?: unknown }).issuer).toBeUndefined(); + }); + + it('should return anything that is not a plain object unchanged', () => { + const list = [{ issuer: 'https://as.example.com' }]; + expect(withoutIssuer(list)).toBe(list); + expect(withoutIssuer('issuer')).toBe('issuer'); + expect(withoutIssuer(null)).toBeNull(); + expect(withoutIssuer(undefined)).toBeUndefined(); + }); + }); }); diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index cf98edd716..d7f7f8858e 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -144,7 +144,13 @@ export const OAuthTokensSchema = z token_type: z.string(), expires_in: z.coerce.number().optional(), scope: z.string().optional(), - refresh_token: z.string().optional() + refresh_token: z.string().optional(), + /** + * Not part of the wire format: the authorization server this value was obtained from, added by + * the client's `auth()` before it is stored and compared when it is read back (SEP-2352). + */ + // eslint-disable-next-line unicorn/prefer-top-level-await -- Zod .catch(), not a Promise chain + issuer: z.string().optional().catch(undefined) }) .strip(); @@ -223,7 +229,13 @@ export const OAuthClientInformationSchema = z client_id: z.string(), client_secret: z.string().optional(), client_id_issued_at: z.number().optional(), - client_secret_expires_at: z.number().optional() + client_secret_expires_at: z.number().optional(), + /** + * Not part of the wire format: the authorization server this value was obtained from, added by + * the client's `auth()` before it is stored and compared when it is read back (SEP-2352). + */ + // eslint-disable-next-line unicorn/prefer-top-level-await -- Zod .catch(), not a Promise chain + issuer: z.string().optional().catch(undefined) }) .strip(); @@ -267,20 +279,18 @@ export type OAuthClientInformationMixed = OAuthClientInformation | OAuthClientIn * {@linkcode OAuthTokens} as persisted by an `OAuthClientProvider`. Adds an * SDK-stamped authorization-server `issuer` identifier so stored tokens are * bound to the AS that issued them. The `issuer` field is **not** part of the - * RFC 6749 wire response and is intentionally absent from the wire-response - * schema; the client SDK writes it before calling `saveTokens`. + * RFC 6749 wire response; the client SDK writes it before calling `saveTokens`. */ -export type StoredOAuthTokens = OAuthTokens & { issuer?: string }; +export type StoredOAuthTokens = OAuthTokens & { issuer?: string | undefined }; /** * {@linkcode OAuthClientInformationMixed} as persisted by an * `OAuthClientProvider`. Adds an SDK-stamped authorization-server `issuer` * identifier so stored client credentials are bound to the AS that issued them. - * The `issuer` field is **not** part of the RFC 7591 wire response and is - * intentionally absent from the wire-response schema; the client SDK writes it - * before calling `saveClientInformation`. + * The `issuer` field is **not** part of the RFC 7591 wire response; the client SDK + * writes it before calling `saveClientInformation`. */ -export type StoredOAuthClientInformation = OAuthClientInformationMixed & { issuer?: string }; +export type StoredOAuthClientInformation = OAuthClientInformationMixed & { issuer?: string | undefined }; export type OAuthClientRegistrationError = z.infer; export type OAuthTokenRevocationRequest = z.infer; diff --git a/packages/server-legacy/src/auth/providers/proxyProvider.ts b/packages/server-legacy/src/auth/providers/proxyProvider.ts index 347589198a..692dd29121 100644 --- a/packages/server-legacy/src/auth/providers/proxyProvider.ts +++ b/packages/server-legacy/src/auth/providers/proxyProvider.ts @@ -1,5 +1,5 @@ import type { FetchLike, OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/core-internal'; -import { OAuthClientInformationFullSchema, OAuthTokensSchema } from '@modelcontextprotocol/core-internal'; +import { OAuthClientInformationFullSchema, OAuthTokensSchema, withoutIssuer } from '@modelcontextprotocol/core-internal'; import type { Response } from 'express'; import type { OAuthRegisteredClientsStore } from '../clients'; @@ -119,7 +119,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { } const data = await response.json(); - return OAuthClientInformationFullSchema.parse(data); + return OAuthClientInformationFullSchema.parse(withoutIssuer(data)); } }) }; @@ -194,7 +194,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { } const data = await response.json(); - return OAuthTokensSchema.parse(data); + return OAuthTokensSchema.parse(withoutIssuer(data)); } async exchangeRefreshToken( @@ -235,7 +235,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { } const data = await response.json(); - return OAuthTokensSchema.parse(data); + return OAuthTokensSchema.parse(withoutIssuer(data)); } async verifyAccessToken(token: string): Promise { diff --git a/packages/server-legacy/test/auth/providers/proxyProvider.test.ts b/packages/server-legacy/test/auth/providers/proxyProvider.test.ts index 7915435eac..014caf8aa3 100644 --- a/packages/server-legacy/test/auth/providers/proxyProvider.test.ts +++ b/packages/server-legacy/test/auth/providers/proxyProvider.test.ts @@ -233,6 +233,23 @@ describe('Proxy OAuth Server Provider', () => { ); expect(tokens).toEqual(mockTokenResponse); }); + + it('does not forward an issuer from the upstream token response', async () => { + (global.fetch as Mock).mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ ...mockTokenResponse, issuer: 'https://upstream.example.com' }) + }) + ); + + const exchanged = await provider.exchangeAuthorizationCode(validClient, 'test-code', 'test-verifier'); + const refreshed = await provider.exchangeRefreshToken(validClient, 'test-refresh-token'); + + expect(exchanged).toEqual(mockTokenResponse); + expect(exchanged).not.toHaveProperty('issuer'); + expect(refreshed).toEqual(mockTokenResponse); + expect(refreshed).not.toHaveProperty('issuer'); + }); }); describe('client registration', () => { @@ -264,6 +281,25 @@ describe('Proxy OAuth Server Provider', () => { expect(result).toEqual(newClient); }); + it('does not forward an issuer from the upstream registration response', async () => { + const newClient: OAuthClientInformationFull = { + client_id: 'new-client', + redirect_uris: ['https://new-client.com/callback'] + }; + + (global.fetch as Mock).mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ ...newClient, issuer: 'https://upstream.example.com' }) + }) + ); + + const result = await provider.clientsStore.registerClient!(newClient); + + expect(result).toEqual(newClient); + expect(result).not.toHaveProperty('issuer'); + }); + it('handles registration failure', async () => { mockFailedResponse(); const newClient: OAuthClientInformationFull = { diff --git a/test/e2e/scenarios/client-auth.test.ts b/test/e2e/scenarios/client-auth.test.ts index 6441abd593..556cd29ef8 100644 --- a/test/e2e/scenarios/client-auth.test.ts +++ b/test/e2e/scenarios/client-auth.test.ts @@ -1038,7 +1038,7 @@ verifies('client-auth:client-credentials', async (_args: TestArgs) => { return baseFetch(url, init); }; - const provider = new ClientCredentialsProvider({ clientId: CLIENT_ID, clientSecret: CLIENT_SECRET }); + const provider = new ClientCredentialsProvider({ clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, expectedIssuer: ISSUER }); const client = new Client({ name: 'c', version: '0' }); const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch }); @@ -1276,7 +1276,12 @@ verifies('client-auth:private-key-jwt', async (_args: TestArgs) => { const privateKeyPem = keyPair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); const publicKeyPem = keyPair.publicKey.export({ type: 'spki', format: 'pem' }).toString(); - const provider = new PrivateKeyJwtProvider({ clientId: CLIENT_ID, privateKey: privateKeyPem, algorithm: 'RS256' }); + const provider = new PrivateKeyJwtProvider({ + clientId: CLIENT_ID, + privateKey: privateKeyPem, + algorithm: 'RS256', + expectedIssuer: ISSUER + }); const client = new Client({ name: 'c', version: '0' }); const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch }); @@ -1722,7 +1727,8 @@ verifies('client-auth:private-key-jwt:static-assertion', async (_args: TestArgs) const provider = new StaticPrivateKeyJwtProvider({ clientId: CLIENT_ID, - jwtBearerAssertion: preBuiltJwt + jwtBearerAssertion: preBuiltJwt, + expectedIssuer: ISSUER }); const client = new Client({ name: 'c', version: '0' }); From c55efa62fc4218592418ffbd313d1a906286f1d3 Mon Sep 17 00:00:00 2001 From: Sushant Date: Tue, 29 Sep 2026 00:20:06 +0530 Subject: [PATCH 18/24] fix(server): close a listen stream that has honored nothing (#2651) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .../listen-close-when-nothing-honored.md | 5 ++ packages/server/src/server/listenRouter.ts | 6 +++ .../server/createMcpHandlerListen.test.ts | 54 +++++++++++++++++++ test/e2e/scenarios/subscriptions.test.ts | 2 +- 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 .changeset/listen-close-when-nothing-honored.md diff --git a/.changeset/listen-close-when-nothing-honored.md b/.changeset/listen-close-when-nothing-honored.md new file mode 100644 index 0000000000..74e61fdaae --- /dev/null +++ b/.changeset/listen-close-when-nothing-honored.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +`createMcpHandler` now ends a `subscriptions/listen` stream right after the acknowledgement when it honored none of the requested notification types, instead of holding the stream open with nothing to deliver. The client receives the acknowledgement and then the `resultType: "complete"` result. Streams that honor at least one type are unchanged. diff --git a/packages/server/src/server/listenRouter.ts b/packages/server/src/server/listenRouter.ts index 40c4a38cf2..32ae8f26df 100644 --- a/packages/server/src/server/listenRouter.ts +++ b/packages/server/src/server/listenRouter.ts @@ -213,6 +213,12 @@ export function createListenRouter(options: ListenRouterOptions): ListenRouter { ); writeNotification(ack.method, ack.params); + // Nothing honored means nothing can ever be delivered: complete the subscription instead of holding the stream. + if (Object.keys(honored).length === 0) { + teardown(true); + return; + } + // Only after the ack frame is enqueued does delivery activate. unsubscribe = bus.subscribe(event => { if (closed || !listenFilterAccepts(honored, event)) return; diff --git a/packages/server/test/server/createMcpHandlerListen.test.ts b/packages/server/test/server/createMcpHandlerListen.test.ts index fe17aa7536..392887f0b3 100644 --- a/packages/server/test/server/createMcpHandlerListen.test.ts +++ b/packages/server/test/server/createMcpHandlerListen.test.ts @@ -264,6 +264,60 @@ describe('createMcpHandler — subscriptions/listen', () => { }); }); + it('acks and closes when capabilities honor nothing, instead of holding the stream', async () => { + // A server declaring no listChanged and no resources.subscribe honors + // nothing, so the stream can never carry a notification: every + // listenFilterAccepts({}, event) is false. It should end, not idle. + const bareFactory = () => new McpServer({ name: 'listen-test-server', version: '1.0.0' }, { capabilities: {} }); + const handler = createMcpHandler(bareFactory, { keepAliveMs: 0 }); + const response = await handler.fetch( + listenRequest(7, { toolsListChanged: true, resourcesListChanged: true, resourceSubscriptions: ['file:///a'] }) + ); + + // Reading the body to its end only resolves when the server ends the stream itself. + const body = await response.text(); + const messages = body + .split('\n\n') + .filter(frame => frame.length > 0) + .map(frame => JSON.parse(frame.slice(frame.indexOf('data: ') + 'data: '.length)) as unknown); + + expect(messages).toEqual([ + { + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { notifications: {}, _meta: { [SUBSCRIPTION_ID_META_KEY]: 7 } } + }, + { + jsonrpc: '2.0', + id: 7, + result: { + resultType: 'complete', + _meta: { + [SUBSCRIPTION_ID_META_KEY]: 7, + 'io.modelcontextprotocol/serverInfo': { name: 'listen-test-server', version: '1.0.0' } + } + } + } + ]); + }); + + it('still holds the stream when at least one type is honored', async () => { + // The narrowing must not close a stream that can still deliver: tools + // is honored here, resources is not. + const partialFactory = () => + new McpServer({ name: 'listen-test-server', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } }); + const handler = createMcpHandler(partialFactory, { keepAliveMs: 0 }); + const response = await handler.fetch(listenRequest(8, { toolsListChanged: true, resourcesListChanged: true })); + + const [ack] = await readMessages(response, 1); + expect(ack).toEqual({ + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { notifications: { toolsListChanged: true }, _meta: { [SUBSCRIPTION_ID_META_KEY]: 8 } } + }); + await handler.close(); + }); + it('legacy-classified listen never reaches the entry listen router (no ack delivered)', async () => { const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0 }); // No envelope claim → classified legacy → dispatched through the diff --git a/test/e2e/scenarios/subscriptions.test.ts b/test/e2e/scenarios/subscriptions.test.ts index ce644fa430..ef3a42e444 100644 --- a/test/e2e/scenarios/subscriptions.test.ts +++ b/test/e2e/scenarios/subscriptions.test.ts @@ -276,7 +276,7 @@ verifies('subscriptions:listen:capacity-guard', async () => { jsonrpc: '2.0', id, method: 'subscriptions/listen', - params: { _meta: modernEnvelopeMeta(), notifications: {} } + params: { _meta: modernEnvelopeMeta(), notifications: { toolsListChanged: true } } }) }) ); From dd22ba25ddbe7cd8822e1fe9c738a974996a28f6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:05:29 +0100 Subject: [PATCH 19/24] Version Packages (#2849) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/await-notification-send.md | 6 ---- .changeset/client-cjs-types-inline-jose.md | 5 --- .changeset/codemod-preserve-file-header.md | 5 --- .../correct-token-endpoint-tls-citation.md | 5 --- .changeset/curvy-rivers-restore.md | 5 --- .changeset/dcr-deprecation-citation.md | 5 --- .../expected-issuer-deprecate-omission.md | 10 ------ .changeset/input-required-meta-passthrough.md | 5 --- .changeset/keep-next-cursor-on-early-stop.md | 5 --- .../listen-close-when-nothing-honored.md | 5 --- .../listen-opening-unhandled-rejection.md | 5 --- .changeset/loopback-localhost-subdomains.md | 5 --- packages/client/CHANGELOG.md | 31 +++++++++++++++++++ packages/client/package.json | 2 +- packages/codemod/CHANGELOG.md | 6 ++++ packages/codemod/package.json | 2 +- packages/core-internal/CHANGELOG.md | 7 +++++ packages/core-internal/package.json | 2 +- packages/core/CHANGELOG.md | 10 ++++++ packages/core/package.json | 2 +- packages/server-legacy/CHANGELOG.md | 7 +++++ packages/server-legacy/package.json | 2 +- packages/server/CHANGELOG.md | 13 ++++++++ packages/server/package.json | 2 +- 24 files changed, 80 insertions(+), 72 deletions(-) delete mode 100644 .changeset/await-notification-send.md delete mode 100644 .changeset/client-cjs-types-inline-jose.md delete mode 100644 .changeset/codemod-preserve-file-header.md delete mode 100644 .changeset/correct-token-endpoint-tls-citation.md delete mode 100644 .changeset/curvy-rivers-restore.md delete mode 100644 .changeset/dcr-deprecation-citation.md delete mode 100644 .changeset/expected-issuer-deprecate-omission.md delete mode 100644 .changeset/input-required-meta-passthrough.md delete mode 100644 .changeset/keep-next-cursor-on-early-stop.md delete mode 100644 .changeset/listen-close-when-nothing-honored.md delete mode 100644 .changeset/listen-opening-unhandled-rejection.md delete mode 100644 .changeset/loopback-localhost-subdomains.md diff --git a/.changeset/await-notification-send.md b/.changeset/await-notification-send.md deleted file mode 100644 index 2df131c33c..0000000000 --- a/.changeset/await-notification-send.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@modelcontextprotocol/client': patch -'@modelcontextprotocol/server': patch ---- - -Sending a notification on a closed connection no longer produces a briefly unhandled promise rejection (seen as `unhandledrejection` on Cloudflare Workers) in addition to the returned rejection. diff --git a/.changeset/client-cjs-types-inline-jose.md b/.changeset/client-cjs-types-inline-jose.md deleted file mode 100644 index 5c2113c5ca..0000000000 --- a/.changeset/client-cjs-types-inline-jose.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/client': patch ---- - -Fix a type-check failure for CommonJS TypeScript projects introduced in 2.1.0: `dist/index.d.cts` imported types from `jose`, which is ESM-only, so `tsc` with `module: node16`/`node18` and `skipLibCheck: false` failed with TS1479. The two `jose` types used by the DPoP API (`CryptoKey`, `JWK`) are now inlined into the declaration files. No runtime change. diff --git a/.changeset/codemod-preserve-file-header.md b/.changeset/codemod-preserve-file-header.md deleted file mode 100644 index b686d36080..0000000000 --- a/.changeset/codemod-preserve-file-header.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/codemod': patch ---- - -The `v1-to-v2` codemod now writes rewritten imports where the first v1 import stood, not at the top of the file, so a license header, `// @ts-nocheck`, `/// ` or a `'use client'` / `'use server'` / `'use strict'` directive above it stays in place. Known gap: when a later step of the codemod replaces or removes the import (for example a file whose only SDK import is `ErrorCode` or `StreamableHTTPError`), the new import can still land above or inside the header, and a `/** */` header can be removed. Files already migrated with codemod 2.1.0 or earlier are not repaired; check the top of those files. diff --git a/.changeset/correct-token-endpoint-tls-citation.md b/.changeset/correct-token-endpoint-tls-citation.md deleted file mode 100644 index 50dd1d3220..0000000000 --- a/.changeset/correct-token-endpoint-tls-citation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/client': patch ---- - -Correct the JSDoc for insecure OAuth token endpoints. The TLS requirement comes from the MCP authorization specification's OAuth 2.1 communication-security rules, not SEP-2207, which covers OIDC-flavored refresh-token guidance. Documentation only; no runtime behavior change. diff --git a/.changeset/curvy-rivers-restore.md b/.changeset/curvy-rivers-restore.md deleted file mode 100644 index 1c15f14631..0000000000 --- a/.changeset/curvy-rivers-restore.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/server': patch ---- - -Fix a stack overflow in `createMcpHandler` when the factory returns the same server instance for more than one request. Returning a fresh instance per request is still required. diff --git a/.changeset/dcr-deprecation-citation.md b/.changeset/dcr-deprecation-citation.md deleted file mode 100644 index 14cb84d7e4..0000000000 --- a/.changeset/dcr-deprecation-citation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/client': patch ---- - -Correct the `registerClient` `@deprecated` notice: Dynamic Client Registration was deprecated by spec PR modelcontextprotocol#2858 (Client ID Metadata Documents), not SEP-2577 (which deprecates roots, sampling, and logging). The notice now also names the earliest possible removal date under the feature lifecycle policy (2027-07-28) and clarifies that the `client_id_metadata_document_supported` gating lives in the built-in `auth()` flow — `registerClient` called directly always sends the registration request. Documentation only; no runtime behavior change. diff --git a/.changeset/expected-issuer-deprecate-omission.md b/.changeset/expected-issuer-deprecate-omission.md deleted file mode 100644 index 8fa6d07a42..0000000000 --- a/.changeset/expected-issuer-deprecate-omission.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@modelcontextprotocol/client': minor -'@modelcontextprotocol/core': minor ---- - -Constructing `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider` or `CrossAppAccessProvider` without `expectedIssuer` is deprecated: the constructor logs one `console.warn` and that call signature is marked `@deprecated`. Behaviour is otherwise unchanged. Pass the `issuer` of the authorization server the credentials were registered with. - -`fetchToken()` throws `AuthorizationServerMismatchError`, before sending anything, when the provider's client information is bound to a different authorization server than the one it is called with. The `AuthorizationServerMismatchError` message no longer assumes the authorization-code callback; its fields are unchanged. - -`OAuthTokensSchema` and `OAuthClientInformationSchema` accept the optional `issuer` stamp, so a provider that reads storage back through them keeps it. `auth()` overwrites it on every save. diff --git a/.changeset/input-required-meta-passthrough.md b/.changeset/input-required-meta-passthrough.md deleted file mode 100644 index 6f689097e7..0000000000 --- a/.changeset/input-required-meta-passthrough.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/client': patch ---- - -Preserve `_meta` on `input_required` results. The 2026-07-28 decode seam rebuilt the payload from `inputRequests` and `requestState` only, so result-level metadata a server sent on an `input_required` result (including `io.modelcontextprotocol/serverInfo`) was dropped before an `allowInputRequired: true` caller could see it. `Result._meta` is a result-level field, so `input_required` carries it exactly like any other result. diff --git a/.changeset/keep-next-cursor-on-early-stop.md b/.changeset/keep-next-cursor-on-early-stop.md deleted file mode 100644 index a56a05d899..0000000000 --- a/.changeset/keep-next-cursor-on-early-stop.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/client': patch ---- - -`listTools()`, `listPrompts()`, `listResources()` and `listResourceTemplates()` called without a cursor now follow `nextCursor` until the server stops sending one, instead of stopping silently with a short list when a cursor repeats; a page that has the same items and the same `nextCursor` as the page before it ends the walk and is not added twice, and `listMaxPages` still caps the walk. diff --git a/.changeset/listen-close-when-nothing-honored.md b/.changeset/listen-close-when-nothing-honored.md deleted file mode 100644 index 74e61fdaae..0000000000 --- a/.changeset/listen-close-when-nothing-honored.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/server': patch ---- - -`createMcpHandler` now ends a `subscriptions/listen` stream right after the acknowledgement when it honored none of the requested notification types, instead of holding the stream open with nothing to deliver. The client receives the acknowledgement and then the `resultType: "complete"` result. Streams that honor at least one type are unchanged. diff --git a/.changeset/listen-opening-unhandled-rejection.md b/.changeset/listen-opening-unhandled-rejection.md deleted file mode 100644 index a951b4d51a..0000000000 --- a/.changeset/listen-opening-unhandled-rejection.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/client': patch ---- - -Fix `Client.listen()` rejections escaping as process-level unhandled rejections. The internal `opening` promise could reject (ack timeout, transport close, server cancel, caller abort) while `listen()` was still serially awaiting `transport.send(...)`, so no rejection handler was attached yet — the rejection surfaced as an `unhandledRejection` that caller-side handling cannot prevent, and a send that never settles (e.g. a stdio write parked on `'drain'`) left `listen()` suspended forever even though the ack timer had already fired. `listen()` now suspends on the `opening` state machine directly and routes send failures into it, so every termination path rejects the returned promise and nothing escapes. diff --git a/.changeset/loopback-localhost-subdomains.md b/.changeset/loopback-localhost-subdomains.md deleted file mode 100644 index 938ec481fd..0000000000 --- a/.changeset/loopback-localhost-subdomains.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/client': patch ---- - -Treat hostnames ending in `.localhost` as loopback for the SEP-2207 token-endpoint https guard (RFC 6761 §6.3), so host-based multi-tenant local OAuth works. The SDK does not resolve the name itself: `*.localhost` reaches the local machine only if the system resolver follows RFC 6761. diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index 6c39696494..a4c620cc35 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,36 @@ # @modelcontextprotocol/client +## 2.2.0 + +### Minor Changes + +- [#2887](https://github.com/modelcontextprotocol/typescript-sdk/pull/2887) [`edd12e2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/edd12e282620ebf770d67316f19cf91d4112a1bd) Thanks [@maxisbey](https://github.com/maxisbey)! - Constructing `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider` or `CrossAppAccessProvider` without `expectedIssuer` is deprecated: the constructor logs one `console.warn` and that call signature is marked `@deprecated`. Behaviour is otherwise unchanged. Pass the `issuer` of the authorization server the credentials were registered with. + + `fetchToken()` throws `AuthorizationServerMismatchError`, before sending anything, when the provider's client information is bound to a different authorization server than the one it is called with. The `AuthorizationServerMismatchError` message no longer assumes the authorization-code callback; its fields are unchanged. + + `OAuthTokensSchema` and `OAuthClientInformationSchema` accept the optional `issuer` stamp, so a provider that reads storage back through them keeps it. `auth()` overwrites it on every save. + +### Patch Changes + +- [#2885](https://github.com/modelcontextprotocol/typescript-sdk/pull/2885) [`9dd722f`](https://github.com/modelcontextprotocol/typescript-sdk/commit/9dd722fd0b533b3cb44cd2834409f7ea4dd32b00) Thanks [@claude](https://github.com/apps/claude)! - Sending a notification on a closed connection no longer produces a briefly unhandled promise rejection (seen as `unhandledrejection` on Cloudflare Workers) in addition to the returned rejection. + +- [#2883](https://github.com/modelcontextprotocol/typescript-sdk/pull/2883) [`c0f7aec`](https://github.com/modelcontextprotocol/typescript-sdk/commit/c0f7aec2925a46b32dc2f2aad5bf636a36f02c78) Thanks [@claude](https://github.com/apps/claude)! - Fix a type-check failure for CommonJS TypeScript projects introduced in 2.1.0: `dist/index.d.cts` imported types from `jose`, which is ESM-only, so `tsc` with `module: node16`/`node18` and `skipLibCheck: false` failed with TS1479. The two `jose` types used by the DPoP API (`CryptoKey`, `JWK`) are now inlined into the declaration files. No runtime change. + +- [#2768](https://github.com/modelcontextprotocol/typescript-sdk/pull/2768) [`efebf5b`](https://github.com/modelcontextprotocol/typescript-sdk/commit/efebf5b2ba2f07c6fa27ada824a0999b801fec0a) Thanks [@web-abin](https://github.com/web-abin)! - Correct the JSDoc for insecure OAuth token endpoints. The TLS requirement comes from the MCP authorization specification's OAuth 2.1 communication-security rules, not SEP-2207, which covers OIDC-flavored refresh-token guidance. Documentation only; no runtime behavior change. + +- [#2729](https://github.com/modelcontextprotocol/typescript-sdk/pull/2729) [`a4ae2f9`](https://github.com/modelcontextprotocol/typescript-sdk/commit/a4ae2f98da3814a9290f2791cfcca8148dcec978) Thanks [@claude](https://github.com/apps/claude)! - Correct the `registerClient` `@deprecated` notice: Dynamic Client Registration was deprecated by spec PR modelcontextprotocol#2858 (Client ID Metadata Documents), not SEP-2577 (which deprecates roots, sampling, and logging). The notice now also names the earliest possible removal date under the feature lifecycle policy (2027-07-28) and clarifies that the `client_id_metadata_document_supported` gating lives in the built-in `auth()` flow — `registerClient` called directly always sends the registration request. Documentation only; no runtime behavior change. + +- [#2862](https://github.com/modelcontextprotocol/typescript-sdk/pull/2862) [`e780e13`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e780e13869ad419a864b669bd5fa9702cfb36b14) Thanks [@SyedTashfin](https://github.com/SyedTashfin)! - Preserve `_meta` on `input_required` results. The 2026-07-28 decode seam rebuilt the payload from `inputRequests` and `requestState` only, so result-level metadata a server sent on an `input_required` result (including `io.modelcontextprotocol/serverInfo`) was dropped before an `allowInputRequired: true` caller could see it. `Result._meta` is a result-level field, so `input_required` carries it exactly like any other result. + +- [#2886](https://github.com/modelcontextprotocol/typescript-sdk/pull/2886) [`ef39308`](https://github.com/modelcontextprotocol/typescript-sdk/commit/ef39308e963496b75e50553f6265d99cacb4fbb9) Thanks [@claude](https://github.com/apps/claude)! - `listTools()`, `listPrompts()`, `listResources()` and `listResourceTemplates()` called without a cursor now follow `nextCursor` until the server stops sending one, instead of stopping silently with a short list when a cursor repeats; a page that has the same items and the same `nextCursor` as the page before it ends the walk and is not added twice, and `listMaxPages` still caps the walk. + +- [#2642](https://github.com/modelcontextprotocol/typescript-sdk/pull/2642) [`cfa09db`](https://github.com/modelcontextprotocol/typescript-sdk/commit/cfa09db614308eaf4fd575c89833da8878d4acb7) Thanks [@claude](https://github.com/apps/claude)! - Fix `Client.listen()` rejections escaping as process-level unhandled rejections. The internal `opening` promise could reject (ack timeout, transport close, server cancel, caller abort) while `listen()` was still serially awaiting `transport.send(...)`, so no rejection handler was attached yet — the rejection surfaced as an `unhandledRejection` that caller-side handling cannot prevent, and a send that never settles (e.g. a stdio write parked on `'drain'`) left `listen()` suspended forever even though the ack timer had already fired. `listen()` now suspends on the `opening` state machine directly and routes send failures into it, so every termination path rejects the returned promise and nothing escapes. + +- [#2597](https://github.com/modelcontextprotocol/typescript-sdk/pull/2597) [`7f7a94c`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7f7a94c22017e121a960e071bb50ec75e34450bd) Thanks [@arimu1](https://github.com/arimu1)! - Treat hostnames ending in `.localhost` as loopback for the SEP-2207 token-endpoint https guard (RFC 6761 §6.3), so host-based multi-tenant local OAuth works. The SDK does not resolve the name itself: `*.localhost` reaches the local machine only if the system resolver follows RFC 6761. + +- Updated dependencies [[`edd12e2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/edd12e282620ebf770d67316f19cf91d4112a1bd)]: + - @modelcontextprotocol/core@2.2.0 + ## 2.1.0 ### Minor Changes diff --git a/packages/client/package.json b/packages/client/package.json index 997e9bcfff..23c9a6dd82 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/client", - "version": "2.1.0", + "version": "2.2.0", "description": "Model Context Protocol implementation for TypeScript - Client package", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/codemod/CHANGELOG.md b/packages/codemod/CHANGELOG.md index 2a3d2558a4..f40b02ca33 100644 --- a/packages/codemod/CHANGELOG.md +++ b/packages/codemod/CHANGELOG.md @@ -1,5 +1,11 @@ # @modelcontextprotocol/codemod +## 2.2.0 + +### Patch Changes + +- [#2582](https://github.com/modelcontextprotocol/typescript-sdk/pull/2582) [`f091897`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f091897f4ba6c0519584382e31b68a6ca935f35b) Thanks [@axits-lab](https://github.com/axits-lab)! - The `v1-to-v2` codemod now writes rewritten imports where the first v1 import stood, not at the top of the file, so a license header, `// @ts-nocheck`, `/// ` or a `'use client'` / `'use server'` / `'use strict'` directive above it stays in place. Known gap: when a later step of the codemod replaces or removes the import (for example a file whose only SDK import is `ErrorCode` or `StreamableHTTPError`), the new import can still land above or inside the header, and a `/** */` header can be removed. Files already migrated with codemod 2.1.0 or earlier are not repaired; check the top of those files. + ## 2.1.0 ### Patch Changes diff --git a/packages/codemod/package.json b/packages/codemod/package.json index 976e923f8a..030315eaeb 100644 --- a/packages/codemod/package.json +++ b/packages/codemod/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/codemod", - "version": "2.1.0", + "version": "2.2.0", "description": "Codemod to migrate MCP TypeScript SDK code from v1 to v2", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/core-internal/CHANGELOG.md b/packages/core-internal/CHANGELOG.md index 7c96d30e13..fcf3a8c856 100644 --- a/packages/core-internal/CHANGELOG.md +++ b/packages/core-internal/CHANGELOG.md @@ -1,5 +1,12 @@ # @modelcontextprotocol/core-internal +## 2.0.2 + +### Patch Changes + +- Updated dependencies [[`edd12e2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/edd12e282620ebf770d67316f19cf91d4112a1bd)]: + - @modelcontextprotocol/core@2.2.0 + ## 2.0.1 ### Patch Changes diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index 63719b3c2b..1febcabe68 100644 --- a/packages/core-internal/package.json +++ b/packages/core-internal/package.json @@ -1,7 +1,7 @@ { "name": "@modelcontextprotocol/core-internal", "private": true, - "version": "2.0.1", + "version": "2.0.2", "description": "Model Context Protocol implementation for TypeScript - Core package", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 2f1543900b..669b0d07dd 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,15 @@ # @modelcontextprotocol/core +## 2.2.0 + +### Minor Changes + +- [#2887](https://github.com/modelcontextprotocol/typescript-sdk/pull/2887) [`edd12e2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/edd12e282620ebf770d67316f19cf91d4112a1bd) Thanks [@maxisbey](https://github.com/maxisbey)! - Constructing `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider` or `CrossAppAccessProvider` without `expectedIssuer` is deprecated: the constructor logs one `console.warn` and that call signature is marked `@deprecated`. Behaviour is otherwise unchanged. Pass the `issuer` of the authorization server the credentials were registered with. + + `fetchToken()` throws `AuthorizationServerMismatchError`, before sending anything, when the provider's client information is bound to a different authorization server than the one it is called with. The `AuthorizationServerMismatchError` message no longer assumes the authorization-code callback; its fields are unchanged. + + `OAuthTokensSchema` and `OAuthClientInformationSchema` accept the optional `issuer` stamp, so a provider that reads storage back through them keeps it. `auth()` overwrites it on every save. + ## 2.1.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index d11125e961..a321becd5a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/core", - "version": "2.1.0", + "version": "2.2.0", "description": "Model Context Protocol for TypeScript — public Zod schemas (spec + OAuth/OpenID)", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/server-legacy/CHANGELOG.md b/packages/server-legacy/CHANGELOG.md index 553cba100f..84aeffc0b0 100644 --- a/packages/server-legacy/CHANGELOG.md +++ b/packages/server-legacy/CHANGELOG.md @@ -1,5 +1,12 @@ # @modelcontextprotocol/server-legacy +## 2.2.0 + +### Patch Changes + +- Updated dependencies [[`edd12e2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/edd12e282620ebf770d67316f19cf91d4112a1bd)]: + - @modelcontextprotocol/core@2.2.0 + ## 2.1.0 ### Patch Changes diff --git a/packages/server-legacy/package.json b/packages/server-legacy/package.json index 45b9fe65e1..f89b1a6dea 100644 --- a/packages/server-legacy/package.json +++ b/packages/server-legacy/package.json @@ -1,7 +1,7 @@ { "name": "@modelcontextprotocol/server-legacy", "private": false, - "version": "2.1.0", + "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", diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index e82a8dac9c..8ea1932a19 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -1,5 +1,18 @@ # @modelcontextprotocol/server +## 2.2.0 + +### Patch Changes + +- [#2885](https://github.com/modelcontextprotocol/typescript-sdk/pull/2885) [`9dd722f`](https://github.com/modelcontextprotocol/typescript-sdk/commit/9dd722fd0b533b3cb44cd2834409f7ea4dd32b00) Thanks [@claude](https://github.com/apps/claude)! - Sending a notification on a closed connection no longer produces a briefly unhandled promise rejection (seen as `unhandledrejection` on Cloudflare Workers) in addition to the returned rejection. + +- [#2778](https://github.com/modelcontextprotocol/typescript-sdk/pull/2778) [`e3fb9ed`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e3fb9edcdec70ed7c8463548ea9b90bbcb74c4ab) Thanks [@vjymisal0](https://github.com/vjymisal0)! - Fix a stack overflow in `createMcpHandler` when the factory returns the same server instance for more than one request. Returning a fresh instance per request is still required. + +- [#2651](https://github.com/modelcontextprotocol/typescript-sdk/pull/2651) [`c55efa6`](https://github.com/modelcontextprotocol/typescript-sdk/commit/c55efa62fc4218592418ffbd313d1a906286f1d3) Thanks [@sushantkumar23](https://github.com/sushantkumar23)! - `createMcpHandler` now ends a `subscriptions/listen` stream right after the acknowledgement when it honored none of the requested notification types, instead of holding the stream open with nothing to deliver. The client receives the acknowledgement and then the `resultType: "complete"` result. Streams that honor at least one type are unchanged. + +- Updated dependencies [[`edd12e2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/edd12e282620ebf770d67316f19cf91d4112a1bd)]: + - @modelcontextprotocol/core@2.2.0 + ## 2.1.0 ### Minor Changes diff --git a/packages/server/package.json b/packages/server/package.json index 9483242b91..a7f0d2d68f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/server", - "version": "2.1.0", + "version": "2.2.0", "description": "Model Context Protocol implementation for TypeScript - Server package", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 4d94e7b1ccf769d94a7bbba7789f1ee6c7dfdd8c Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:20:44 +0100 Subject: [PATCH 20/24] fix(server): convert tool schemas on demand, not at registration (#2889) Co-authored-by: Claude Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/lazy-tool-schema-conversion.md | 5 + docs/migration/upgrade-to-v2.md | 2 +- packages/server/src/server/mcp.ts | 69 +++---- .../server/lazyToolSchemaConversion.test.ts | 172 ++++++++++++++++++ .../lazyToolSchemaConversionRequests.test.ts | 141 ++++++++++++++ .../test/server/mcpParamValidation.test.ts | 7 +- 6 files changed, 348 insertions(+), 48 deletions(-) create mode 100644 .changeset/lazy-tool-schema-conversion.md create mode 100644 packages/server/test/server/lazyToolSchemaConversion.test.ts create mode 100644 packages/server/test/server/lazyToolSchemaConversionRequests.test.ts diff --git a/.changeset/lazy-tool-schema-conversion.md b/.changeset/lazy-tool-schema-conversion.md new file mode 100644 index 0000000000..61d75a3f1c --- /dev/null +++ b/.changeset/lazy-tool-schema-conversion.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +`registerTool` no longer converts tool schemas up front, so a server built per request stops converting every tool on every request. The warning about an invalid `x-mcp-header` declaration now appears each time tools are listed, not when the tool is registered. diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index b7bacd6adf..843bbd7638 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -672,7 +672,7 @@ codemod inverts the common nesting automatically and flags shapes it cannot rewr **Zod v3 is no longer supported** (v1 peer was `^3.25 || ^4.0`). Check the **declared range** in your `package.json`, not just the installed version: a zod-3 range that satisfied the v1 peer installs and typechecks cleanly under v2 and only fails at -runtime — and quietly: registration swallows the conversion failure, the server starts +runtime — and quietly: registration does not convert the schema, the server starts and connects normally, and the first `tools/list` (so `client.listTools()`) answers with an error pointing at `fromJsonSchema()` while the process keeps running. (Only the deprecated unwrapped raw-shape form with zod-3 field values throws at registration, diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 70a5539bfb..01c63705d4 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -77,12 +77,7 @@ export class McpServer { } = {}; private _registeredTools: { [name: string]: RegisteredTool } = {}; private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; - /** - * Per-tool JSON-converted `inputSchema`, memoized so the SEP-2243 - * registration-time scan and the pre-dispatch validation step share one - * conversion instead of paying it twice per request under the - * per-request-factory `createMcpHandler` model. - */ + /** Per-tool JSON-converted `inputSchema`, filled on first use by `toolInputSchemaJson()`. */ private _toolInputSchemaJson: { [name: string]: Record } = {}; /** @@ -98,15 +93,7 @@ export class McpServer { if (tool === undefined || !tool.enabled) return undefined; if (Object.hasOwn(this._toolInputSchemaJson, name)) return this._toolInputSchemaJson[name]; if (tool.inputSchema === undefined) return EMPTY_OBJECT_JSON_SCHEMA; - // Lazy path: the memo slot is unset because `registerTool`'s eager - // conversion threw (and was swallowed per its "warn, never throw" - // contract) or `update({paramsSchema})`/rename invalidated it. The - // pre-dispatch SEP-2243 caller must not turn that into a 500 for a - // `tools/call` whose body-authoritative dispatch would otherwise - // succeed — return `undefined` so validation is skipped and the - // conversion failure stays where it always surfaced (`tools/list`). - // A successful re-derive is memoized so the per-request-factory - // `createMcpHandler` model does not re-convert on every call. + // A conversion failure returns `undefined` so it surfaces where it always has (`tools/list`). try { const json = standardSchemaToJsonSchema(tool.inputSchema, 'input'); this._toolInputSchemaJson[name] = json; @@ -238,7 +225,7 @@ export class McpServer { title: tool.title, description: tool.description, inputSchema: tool.inputSchema - ? (standardSchemaToJsonSchema(tool.inputSchema, 'input') as Tool['inputSchema']) + ? (convertListedInputSchema(name, tool.inputSchema) as Tool['inputSchema']) : EMPTY_OBJECT_JSON_SCHEMA, annotations: tool.annotations, icons: tool.icons, @@ -889,42 +876,21 @@ export class McpServer { // Validate tool name according to SEP specification validateAndWarnToolName(name); - // SEP-2243 registration-time declaration-validity check (additive: warn, - // never throw — clients enforce by exclusion, servers by header - // validation; a malformed declaration here should not block local - // development against a stdio client that ignores it). The conversion - // is memoized so the pre-dispatch validation step in `createMcpHandler` - // (and `toolInputSchemaJson()`) does not repeat it for the same tool. - // `standardSchemaToJsonSchema` can throw for schemas it cannot convert - // (e.g. a vendor without `~standard.jsonSchema`); the try/catch keeps - // the "warn, never throw" contract. - if (inputSchema !== undefined) { - try { - const json = standardSchemaToJsonSchema(inputSchema, 'input'); - this._toolInputSchemaJson[name] = json; - const scan = scanXMcpHeaderDeclarations(json); - if (!scan.valid) { - console.warn( - `[mcp-sdk] tool '${name}' carries an invalid x-mcp-header declaration and will be excluded by ` + - `conforming Streamable HTTP clients: ${scan.reason}` - ); - } - } catch { - // Conversion failure: leave the cache slot unset so the lazy - // path in `toolInputSchemaJson()` (and `tools/list`) surfaces - // the failure where it always has. - } - } - // Track current handler for executor regeneration let currentHandler = handler; + let outputSchemaJson: Record | undefined; const registeredTool: RegisteredTool = { title, description, inputSchema, outputSchema, - outputSchemaJson: convertOutputSchemaJson(outputSchema), + get outputSchemaJson() { + return (outputSchemaJson ??= convertOutputSchemaJson(registeredTool.outputSchema)); + }, + set outputSchemaJson(value) { + outputSchemaJson = value; + }, annotations, icons, execution, @@ -1353,7 +1319,7 @@ export type RegisteredTool = { outputSchema?: StandardSchemaWithJSON; /** * @hidden - * The converted JSON Schema of `outputSchema`, memoised at registration (and on + * The converted JSON Schema of `outputSchema`, memoised on first use (and on * `update({outputSchema})`) so the `tools/call` handler passes the SAME advertised schema * `tools/list` emits to the wire codec's `projectCallToolResult` — the SEP-2106 `{result:…}` * wrap predicate follows the schema's root, never the runtime value shape. `undefined` when @@ -1413,6 +1379,19 @@ const EMPTY_OBJECT_JSON_SCHEMA = { properties: {} }; +/** Converts a tool's `inputSchema` for `tools/list` and warns on an invalid SEP-2243 `x-mcp-header` declaration. */ +function convertListedInputSchema(name: string, inputSchema: StandardSchemaWithJSON): Record { + const json = standardSchemaToJsonSchema(inputSchema, 'input'); + const scan = scanXMcpHeaderDeclarations(json); + if (!scan.valid) { + console.warn( + `[mcp-sdk] tool '${name}' carries an invalid x-mcp-header declaration and will be excluded by ` + + `conforming Streamable HTTP clients: ${scan.reason}` + ); + } + return json; +} + /** * Convert a registered `outputSchema` to JSON Schema, memoised on {@link RegisteredTool.outputSchemaJson} * so `tools/call` passes the SAME advertised schema to the wire codec's `projectCallToolResult` that diff --git a/packages/server/test/server/lazyToolSchemaConversion.test.ts b/packages/server/test/server/lazyToolSchemaConversion.test.ts new file mode 100644 index 0000000000..073ce16f87 --- /dev/null +++ b/packages/server/test/server/lazyToolSchemaConversion.test.ts @@ -0,0 +1,172 @@ +/** + * `registerTool` converts tool schemas to JSON Schema on demand, not at + * registration: `tools/list` converts the schemas it emits, and the memoised + * `toolInputSchemaJson()` / `outputSchemaJson` slots fill on first use. A server + * built per request therefore no longer converts every tool on every request. + */ +import type { StandardSchemaWithJSON } from '@modelcontextprotocol/core-internal'; +import { scanXMcpHeaderDeclarations, standardSchemaToJsonSchema } from '@modelcontextprotocol/core-internal'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as z from 'zod/v4'; + +import { fromJsonSchema } from '../../src/fromJsonSchema'; +import { invoke } from '../../src/server/invoke'; +import { McpServer } from '../../src/server/mcp'; + +const LEGACY = { classification: { era: 'legacy' as const } }; + +type Counted = { schema: StandardSchemaWithJSON; calls: { input: number; output: number } }; + +/** Wraps a schema so every `~standard.jsonSchema` conversion is counted. */ +function counted(inner: StandardSchemaWithJSON): Counted { + const calls = { input: 0, output: 0 }; + const std = inner['~standard']; + const schema = { + '~standard': { + ...std, + jsonSchema: { + input: (options: Parameters[0]) => { + calls.input++; + return std.jsonSchema.input(options); + }, + output: (options: Parameters[0]) => { + calls.output++; + return std.jsonSchema.output(options); + } + } + } + } as unknown as StandardSchemaWithJSON; + return { schema, calls }; +} + +const listTools = async (server: McpServer): Promise<{ status: number; body: Record }> => { + const response = await invoke(server, { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, LEGACY); + return { status: response.status, body: (await response.json()) as Record }; +}; + +const INVALID_HEADER_SCHEMA = fromJsonSchema({ + type: 'object', + properties: { a: { type: 'object', 'x-mcp-header': 'Data' } as Record } +}); + +describe('lazy tool schema conversion', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('registerTool converts neither inputSchema nor outputSchema; tools/list converts both', async () => { + const input = counted(z.object({ a: z.string() })); + const output = counted(z.object({ b: z.number() })); + const server = new McpServer({ name: 'lazy', version: '0' }); + + server.registerTool('t', { inputSchema: input.schema, outputSchema: output.schema }, async () => ({ content: [] })); + expect(input.calls).toEqual({ input: 0, output: 0 }); + expect(output.calls).toEqual({ input: 0, output: 0 }); + + const { body } = await listTools(server); + expect((body.result as { tools: unknown[] }).tools).toHaveLength(1); + expect(input.calls).toEqual({ input: 1, output: 0 }); + expect(output.calls).toEqual({ input: 0, output: 1 }); + }); + + it('toolInputSchemaJson() converts on first use and memoises', () => { + const input = counted(z.object({ a: z.string() })); + const server = new McpServer({ name: 'lazy', version: '0' }); + server.registerTool('t', { inputSchema: input.schema }, async () => ({ content: [] })); + expect(input.calls.input).toBe(0); + + const first = server.toolInputSchemaJson('t'); + expect(first).toEqual(standardSchemaToJsonSchema(z.object({ a: z.string() }), 'input')); + expect(input.calls.input).toBe(1); + + expect(server.toolInputSchemaJson('t')).toBe(first); + expect(input.calls.input).toBe(1); + }); + + it('outputSchemaJson converts on first read and memoises', () => { + const output = counted(z.object({ b: z.number() })); + const server = new McpServer({ name: 'lazy', version: '0' }); + const tool = server.registerTool('t', { outputSchema: output.schema }, async () => ({ content: [] })); + expect(output.calls.output).toBe(0); + + const first = tool.outputSchemaJson; + expect(first).toEqual(standardSchemaToJsonSchema(z.object({ b: z.number() }), 'output')); + expect(output.calls.output).toBe(1); + + expect(tool.outputSchemaJson).toBe(first); + expect(output.calls.output).toBe(1); + }); + + it('update({ outputSchema }) replaces outputSchemaJson', () => { + const server = new McpServer({ name: 'lazy', version: '0' }); + const tool = server.registerTool('t', { outputSchema: z.object({ b: z.number() }) }, async () => ({ content: [] })); + expect(tool.outputSchemaJson).toEqual(standardSchemaToJsonSchema(z.object({ b: z.number() }), 'output')); + + const replacement = counted(z.object({ c: z.string() })); + tool.update({ outputSchema: replacement.schema }); + expect(tool.outputSchemaJson).toEqual(standardSchemaToJsonSchema(z.object({ c: z.string() }), 'output')); + expect(replacement.calls.output).toBe(1); + // The replacement stays memoised. + void tool.outputSchemaJson; + expect(replacement.calls.output).toBe(1); + }); + + it('the invalid x-mcp-header warning fires each time tools are listed, not at registration', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const server = new McpServer({ name: 'lazy', version: '0' }); + server.registerTool('bad', { inputSchema: INVALID_HEADER_SCHEMA }, async () => ({ content: [] })); + expect(warn).toHaveBeenCalledTimes(0); + + await listTools(server); + await listTools(server); + await listTools(server); + expect(warn).toHaveBeenCalledTimes(3); + const scan = scanXMcpHeaderDeclarations(standardSchemaToJsonSchema(INVALID_HEADER_SCHEMA, 'input')); + expect(scan.valid).toBe(false); + for (const call of warn.mock.calls) { + expect(call[0]).toBe( + `[mcp-sdk] tool 'bad' carries an invalid x-mcp-header declaration and will be excluded by ` + + `conforming Streamable HTTP clients: ${(scan as { reason?: string }).reason}` + ); + } + }); + + it('tools/list emits the same bytes as a direct conversion', async () => { + const server = new McpServer({ name: 'lazy', version: '0' }); + const inputSchema = z.object({ a: z.string().describe('first'), n: z.number().optional() }); + const outputSchema = z.object({ ok: z.boolean() }); + server.registerTool('t', { title: 'T', description: 'd', inputSchema, outputSchema }, async () => ({ content: [] })); + server.registerTool('bare', {}, async () => ({ content: [] })); + + const { body } = await listTools(server); + expect(JSON.stringify(body.result)).toBe( + JSON.stringify({ + tools: [ + { + name: 't', + title: 'T', + description: 'd', + inputSchema: standardSchemaToJsonSchema(inputSchema, 'input'), + outputSchema: standardSchemaToJsonSchema(outputSchema, 'output') + }, + { name: 'bare', inputSchema: { type: 'object', properties: {} } } + ] + }) + ); + }); + + it('a conversion failure still surfaces at tools/list, and toolInputSchemaJson() returns undefined', async () => { + const unconvertible = { + '~standard': { version: 1, vendor: 'custom', validate: (value: unknown) => ({ value }) } + } as unknown as StandardSchemaWithJSON; + const server = new McpServer({ name: 'lazy', version: '0' }); + // Registration does not convert, so it does not throw. + expect(() => server.registerTool('t', { inputSchema: unconvertible }, async () => ({ content: [] }))).not.toThrow(); + + expect(server.toolInputSchemaJson('t')).toBeUndefined(); + + const { body } = await listTools(server); + expect(body.result).toBeUndefined(); + expect(JSON.stringify(body.error)).toContain('does not implement StandardJSONSchemaV1'); + }); +}); diff --git a/packages/server/test/server/lazyToolSchemaConversionRequests.test.ts b/packages/server/test/server/lazyToolSchemaConversionRequests.test.ts new file mode 100644 index 0000000000..8bf9e182be --- /dev/null +++ b/packages/server/test/server/lazyToolSchemaConversionRequests.test.ts @@ -0,0 +1,141 @@ +// Regression test for #2838: each request converts only the tool schemas it needs. +import type { JSONRPCMessage, StandardSchemaWithJSON } from '@modelcontextprotocol/core-internal'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + InMemoryTransport, + PROTOCOL_VERSION_META_KEY +} from '@modelcontextprotocol/core-internal'; +import { describe, expect, it } from 'vitest'; + +import { createMcpHandler } from '../../src/server/createMcpHandler'; +import { McpServer } from '../../src/server/mcp'; + +const conversions: string[] = []; + +function countingSchema(label: string): StandardSchemaWithJSON { + const convert = () => { + conversions.push(label); + return { type: 'object' as const, properties: { value: { type: 'string' as const } } }; + }; + return { + '~standard': { version: 1, vendor: 'counting', validate: value => ({ value }), jsonSchema: { input: convert, output: convert } } + }; +} + +const schemas = [countingSchema('a'), countingSchema('b'), countingSchema('c')]; +const outputOfA = countingSchema('out-a'); + +function buildServer(): McpServer { + const server = new McpServer({ name: 'stateless', version: '0' }); + for (const [i, inputSchema] of schemas.entries()) { + server.registerTool(`tool_${i}`, { inputSchema, ...(i === 0 ? { outputSchema: outputOfA } : {}) }, async () => ({ + content: [{ type: 'text', text: 'ok' }], + structuredContent: { value: 'ok' } + })); + } + return server; +} + +function legacy(method: string, params: Record): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) + }); +} + +function modern(method: string, params: Record, name?: string): Request { + const envelope = { + [PROTOCOL_VERSION_META_KEY]: '2026-07-28', + [CLIENT_INFO_META_KEY]: { name: 'c', version: '0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': method, + ...(name === undefined ? {} : { 'mcp-name': name }) + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params: { ...params, _meta: envelope } }) + }); +} + +async function conversionsFor(request: Request): Promise { + const handler = createMcpHandler(buildServer); + conversions.length = 0; + const response = await handler.fetch(request); + expect(response.status).toBe(200); + expect(await response.text()).toContain('"result"'); + return [...conversions]; +} + +// Connects a 2025-era peer to one long-lived server and returns a function that sends a request and resolves with its result. +async function connect(server: McpServer) { + const [peer, serverSide] = InMemoryTransport.createLinkedPair(); + const waiters = new Map void>(); + peer.onmessage = message => waiters.get((message as { id?: unknown }).id)?.(message); + await server.connect(serverSide); + await peer.start(); + let id = 0; + const request = (method: string, params: Record) => + new Promise>(resolve => { + waiters.set(++id, message => resolve((message as { result?: Record }).result ?? {})); + void peer.send({ jsonrpc: '2.0', id, method, params }); + }); + await request('initialize', { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'c', version: '0' } }); + await peer.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + return request; +} + +describe('lazy tool schema conversion, seen through requests (#2838)', () => { + it('a request that needs no tool schema converts nothing', async () => { + const initialize = { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'c', version: '0' } }; + expect(await conversionsFor(legacy('initialize', initialize))).toEqual([]); + expect(await conversionsFor(modern('server/discover', {}))).toEqual([]); + }); + + it('tools/call converts only schemas of the called tool', async () => { + const callB = { name: 'tool_1', arguments: { value: 'x' } }; + expect(await conversionsFor(legacy('tools/call', callB))).toEqual([]); + expect(await conversionsFor(modern('tools/call', callB, 'tool_1'))).toEqual(['b']); + const callA = { name: 'tool_0', arguments: { value: 'x' } }; + expect(await conversionsFor(legacy('tools/call', callA))).toEqual(['out-a']); + expect(await conversionsFor(modern('tools/call', callA, 'tool_0'))).toEqual(['a', 'out-a']); + }); + + it('tools/list converts each listed schema once', async () => { + expect(await conversionsFor(legacy('tools/list', {}))).toEqual(['a', 'out-a', 'b', 'c']); + expect(await conversionsFor(modern('tools/list', {}))).toEqual(['a', 'out-a', 'b', 'c']); + }); + + it('a long-lived server converts an output schema at most once, however often the tool is called', async () => { + const request = await connect(buildServer()); + conversions.length = 0; + for (let i = 0; i < 3; i++) await request('tools/call', { name: 'tool_0', arguments: { value: 'x' } }); + expect(conversions.filter(label => label === 'out-a').length).toBeLessThanOrEqual(1); + }); + + it('update({ outputSchema }) replaces the converted output schema', async () => { + const server = new McpServer({ name: 'long-lived', version: '0' }); + const tool = server.registerTool('tool', { inputSchema: schemas[0]!, outputSchema: outputOfA }, async () => ({ + content: [], + structuredContent: { value: 'ok' } + })); + const request = await connect(server); + const before = await request('tools/call', { name: 'tool', arguments: {} }); + expect(before.structuredContent).toEqual({ value: 'ok' }); + + const root = () => ({ anyOf: [{ type: 'string' as const }, { type: 'object' as const }] }); + const textOrObject: StandardSchemaWithJSON = { + '~standard': { version: 1, vendor: 'counting', validate: value => ({ value }), jsonSchema: { input: root, output: root } } + }; + tool.update({ outputSchema: textOrObject }); + // A 2025-era peer gets the result wrapped as { result } when the converted schema's root is not an object. + const after = await request('tools/call', { name: 'tool', arguments: {} }); + expect(after.structuredContent).toEqual({ result: { value: 'ok' } }); + }); +}); diff --git a/packages/server/test/server/mcpParamValidation.test.ts b/packages/server/test/server/mcpParamValidation.test.ts index 531edf05b0..7800a9d01a 100644 --- a/packages/server/test/server/mcpParamValidation.test.ts +++ b/packages/server/test/server/mcpParamValidation.test.ts @@ -8,7 +8,7 @@ * same `HeaderMismatch` shape the inbound classifier emits for the * standard-header cross-checks. A `null`/absent body value passes regardless * of the header (the spec's "server MUST NOT expect" rows). The - * registration-time declaration-validity check warns on invalid declarations. + * declaration-validity check warns on invalid declarations when tools are listed. */ import { CLIENT_CAPABILITIES_META_KEY, @@ -20,6 +20,7 @@ import { describe, expect, it, vi } from 'vitest'; import { fromJsonSchema } from '../../src/fromJsonSchema'; import { createMcpHandler } from '../../src/server/createMcpHandler'; +import { invoke } from '../../src/server/invoke'; import { McpServer } from '../../src/server/mcp'; const MODERN = '2026-07-28'; @@ -118,7 +119,7 @@ describe('SEP-2243 Mcp-Param-* server validation (createMcpHandler, modern era)' }); describe('SEP-2243 registerTool declaration-validity check', () => { - it('warns on an invalid x-mcp-header declaration at registration time', () => { + it('warns on an invalid x-mcp-header declaration when tools are listed, not at registration', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const s = new McpServer({ name: 'warn-server', version: '1.0.0' }); s.registerTool( @@ -131,6 +132,8 @@ describe('SEP-2243 registerTool declaration-validity check', () => { }, async () => ({ content: [] }) ); + expect(warn).not.toHaveBeenCalled(); + await invoke(s, { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, { classification: { era: 'legacy' } }); expect(warn).toHaveBeenCalledWith(expect.stringContaining("tool 'bad' carries an invalid x-mcp-header")); warn.mockRestore(); }); From 7f4c12a6ae6b8f22411f7772c88036e1c8055423 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:43:26 +0100 Subject: [PATCH 21/24] fix(node): make hono a regular dependency (#2897) Co-authored-by: Claude --- .changeset/node-hono-regular-dependency.md | 5 ++++ docs/migration/upgrade-to-v2.md | 6 ++-- packages/middleware/node/package.json | 11 ++----- .../node/test/packageManifest.test.ts | 29 +++++++++++++++++++ 4 files changed, 40 insertions(+), 11 deletions(-) create mode 100644 .changeset/node-hono-regular-dependency.md create mode 100644 packages/middleware/node/test/packageManifest.test.ts diff --git a/.changeset/node-hono-regular-dependency.md b/.changeset/node-hono-regular-dependency.md new file mode 100644 index 0000000000..1a70253b0c --- /dev/null +++ b/.changeset/node-hono-regular-dependency.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/node': patch +--- + +`hono` is now a regular dependency of `@modelcontextprotocol/node`, so installs with strict peer-dependency checking no longer fail on the `hono` peer that `@hono/node-server` requires. No runtime change. diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 843bbd7638..d7b1485d84 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -207,9 +207,9 @@ The framework adapter packages declare their framework as a **peer dependency** (`express`, `hono`, `fastify`); v1 shipped them as direct deps. The codemod adds the `@modelcontextprotocol/*` packages your imports use, but does not add the framework peer — install it explicitly (`pnpm add express` etc.). `@modelcontextprotocol/node` -depends on `@hono/node-server` at runtime (Node HTTP ↔ Web Standard conversion) but -does **not** require the `hono` framework — your package manager may emit a harmless -unmet-peer warning for `hono` (upstream `@hono/node-server` declares it). +depends on `@hono/node-server` at runtime (Node HTTP ↔ Web Standard conversion) and +installs `hono` as a regular dependency only because `@hono/node-server` requires it as +a peer — the adapter does not use the `hono` framework itself. v2 requires **Node.js 20+**. It is ESM-first but ships a **CommonJS build alongside ESM**, so CommonJS projects can `require('@modelcontextprotocol/…')` directly — no diff --git a/packages/middleware/node/package.json b/packages/middleware/node/package.json index 5e5cc6a47c..86807ade45 100644 --- a/packages/middleware/node/package.json +++ b/packages/middleware/node/package.json @@ -49,16 +49,11 @@ "test:watch": "vitest" }, "dependencies": { - "@hono/node-server": "catalog:runtimeServerOnly" - }, - "peerDependencies": { - "@modelcontextprotocol/server": "workspace:^", + "@hono/node-server": "catalog:runtimeServerOnly", "hono": "catalog:runtimeServerOnly" }, - "peerDependenciesMeta": { - "hono": { - "optional": true - } + "peerDependencies": { + "@modelcontextprotocol/server": "workspace:^" }, "devDependencies": { "@modelcontextprotocol/server": "workspace:^", diff --git a/packages/middleware/node/test/packageManifest.test.ts b/packages/middleware/node/test/packageManifest.test.ts new file mode 100644 index 0000000000..41895c6a97 --- /dev/null +++ b/packages/middleware/node/test/packageManifest.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +interface PackageManifest { + dependencies?: Record; + peerDependencies?: Record; + peerDependenciesMeta?: Record; +} + +const manifestPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'); +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as PackageManifest; + +describe('package manifest', () => { + // Regression test for https://github.com/modelcontextprotocol/typescript-sdk/issues/2882: + // `@hono/node-server` declares `hono` as a required peer. If this package only lists + // `hono` as an optional peer, strict pnpm installs fail with ERR_PNPM_PEER_DEP_ISSUES. + it('declares hono as a regular dependency to satisfy the @hono/node-server peer', () => { + expect(manifest.dependencies).toHaveProperty('@hono/node-server'); + expect(manifest.dependencies).toHaveProperty('hono'); + }); + + it('does not declare hono as a peer dependency', () => { + expect(manifest.peerDependencies ?? {}).not.toHaveProperty('hono'); + expect(manifest.peerDependenciesMeta ?? {}).not.toHaveProperty('hono'); + }); +}); From 5238fba4424f82ec1ae9f6f458dd655ab322062d Mon Sep 17 00:00:00 2001 From: freya0926 Date: Wed, 30 Sep 2026 20:40:34 +0900 Subject: [PATCH 22/24] fix(core): serve tasks/get and tasks/cancel on 2026-07-28 with an explicit schema (#2599) Co-authored-by: freya0926 <299410795+freya0926@users.noreply.github.com> Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .../era-gate-explicit-schema-handlers.md | 6 + docs/migration/support-2026-07-28.md | 25 ++- packages/core-internal/src/shared/protocol.ts | 30 ++- packages/core-internal/src/wire/codec.ts | 8 +- .../core-internal/test/wire/eraGates.test.ts | 199 +++++++++++++++++- 5 files changed, 250 insertions(+), 18 deletions(-) create mode 100644 .changeset/era-gate-explicit-schema-handlers.md 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/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index 01ae6f0542..4ade8cf50e 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -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/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/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/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 From 2237555ed036c3e80341c0f3c28684e9c3ff0728 Mon Sep 17 00:00:00 2001 From: Sharvil Saxena <97365286+sharziki@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:45:08 -0400 Subject: [PATCH 23/24] Typecheck the three test workspace packages, and fix the registerPrompt typing defect they were hiding (#2841) Signed-off-by: Sharvil Saxena Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .../register-prompt-context-only-overload.md | 5 ++++ packages/server/src/server/mcp.ts | 15 +++++++++- .../test/server/registerPromptNoArgs.test.ts | 28 +++++++++++++++++++ pnpm-lock.yaml | 9 ++++++ test/conformance/package.json | 2 ++ test/conformance/src/everythingServer.ts | 5 +++- test/conformance/tsconfig.json | 2 ++ test/helpers/package.json | 4 ++- test/integration/package.json | 2 ++ test/integration/test/client/client.test.ts | 8 ++++-- test/integration/test/server.test.ts | 6 ++-- .../test/server/cloudflareWorkers.test.ts | 2 +- .../test/server/declaredCapabilities.test.ts | 4 +-- .../test/server/dualEraStdio.test.ts | 8 +++--- .../test/server/elicitation.test.ts | 13 +++++---- test/integration/test/server/mcp.test.ts | 25 ++++++++--------- test/integration/test/standardSchema.test.ts | 18 ++++++------ 17 files changed, 113 insertions(+), 43 deletions(-) create mode 100644 .changeset/register-prompt-context-only-overload.md create mode 100644 packages/server/test/server/registerPromptNoArgs.test.ts 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/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/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/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/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'] From e765b3be84b7eba1837ba8da84acd6898cc47c65 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:59:15 +0100 Subject: [PATCH 24/24] fix(client): say so when the version probe gets an unusable reply (#2903) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Co-authored-by: Claude --- .../probe-unusable-2xx-reply-message.md | 8 + docs/migration/support-2026-07-28.md | 2 +- docs/protocol-versions.md | 2 + docs/troubleshooting.md | 1 + packages/client/src/client/probeClassifier.ts | 14 ++ .../client/src/client/versionNegotiation.ts | 12 ++ .../test/client/probeClassifier.test.ts | 37 +++++ .../test/client/probeUnusable2xx.wire.test.ts | 156 ++++++++++++++++++ .../test/client/versionNegotiation.test.ts | 78 +++++++++ 9 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 .changeset/probe-unusable-2xx-reply-message.md create mode 100644 packages/client/test/client/probeUnusable2xx.wire.test.ts diff --git a/.changeset/probe-unusable-2xx-reply-message.md b/.changeset/probe-unusable-2xx-reply-message.md new file mode 100644 index 0000000000..d96e183ec8 --- /dev/null +++ b/.changeset/probe-unusable-2xx-reply-message.md @@ -0,0 +1,8 @@ +--- +'@modelcontextprotocol/client': patch +--- + +With `versionNegotiation` in `'auto'` or pin mode, a `server/discover` probe answered with a 2xx that carries no usable reply (an empty or +non-JSON body, a `204`, a missing or unexpected content type) still rejects `connect()` with `EraNegotiationFailed`. 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/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index 4ade8cf50e..7c83465749 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 (an empty or non-JSON body, a `204`, a missing or unexpected content type) is not era evidence either: `connect()` rejects with `SdkError(EraNegotiationFailed)`. 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)` — diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index 06e3d174f2..5972385c42 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 (an empty or non-JSON body, a `204`, a missing or unexpected content type) is not era evidence: `connect()` rejects with `SdkError(EraNegotiationFailed)` and the message says `the server answered with an unusable reply`. 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/troubleshooting.md b/docs/troubleshooting.md index d9e6e7824d..b422d2ced1 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 (an empty or non-JSON body, a `204`, a missing or unexpected content type). 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/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/versionNegotiation.ts b/packages/client/src/client/versionNegotiation.ts index 9b89d962ba..52d8bf3f00 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; + } + // By name, not `instanceof`: an injected `fetch` can produce a SyntaxError from another realm. + 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/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); + }); +});