Merge branch 'v2/main' into v2/docs/1822-migration-guide

Took v2/main's project-layout line and quality-gate table (adding
verify:dep-lockstep), keeping this branch's migration-guide mention and
prettier table alignment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
This commit is contained in:
cliffhall 2026-08-11 11:18:35 -04:00
commit 0a2b7f7179
32 changed files with 2196 additions and 2291 deletions

View file

@ -72,6 +72,13 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo
- `src/types/` is only for ambient `.d.ts` module stubs — not a home for new domain types.
- ⚠️ The coverage `include` is a **whitelist** naming `components` / `hooks` / `theme` / `lib` / `utils` / `server` (plus the `core/*` runtime). A module placed outside those directories silently falls out of the ≥90% gate. Flag new top-level files or new grab-bag directories.
## Dependency placement
- **The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) belong in the root `package.json` only.** Adding one to a `clients/*/package.json` is a review finding: Node resolution walks up, so the root install already serves every client, and a per-client entry installs a second copy that drifts (it produced two versions of `ext-apps`, and of the transitive v1 SDK, at once — #1970) and reintroduces the duplicate-copy failure `vitest.shared.mts` has a `dedupe` workaround for.
- **The v1 `@modelcontextprotocol/sdk` is not a dependency of this repo** and must not become one. It is a peer of `ext-apps`, present in lock files only.
- Dependencies reached only through **root-owned code with no manifest** (`test-servers/src`, `core/`) are declared at the root and aliased to the **repo root** in `vitest.shared.mts` — as `express` and `yaml` are — not to `<client>/node_modules` like the other pins there.
- **Which section is a separate question from which manifest.** A package `core/` imports at runtime must be in root **`dependencies`**: client builds externalize npm packages, so a published install resolves them from the root manifest and devDependencies are absent there. Only test/build-only packages (`express`) belong in `devDependencies`. Flag a runtime `core/` import added to `devDependencies` — it passes every local check and breaks the published package.
## Tests and the coverage gate
- **All new or modified code needs tests.** The per-file gate is **≥ 90% on all four dimensions** — lines, statements, functions, **and branches** — enforced in CI for `clients/{web,cli,tui,launcher}` and the gated `core/` runtime.
@ -92,6 +99,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo
## Gates and PR hygiene
- `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook). `npm run validate` is the fast inner-loop check and is **not** a substitute — it runs `test`, not `test:coverage`, so it does zero coverage gating.
- **A dependency bump must land in every install that declares it.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this, so a PR bumping a package that the shared sources import should update the root **and every client that already lists it** — not every client unconditionally, since a package absent from an install can't skew and adding it there would be a spurious dependency.
- **Every PR references an issue**, first body line `Closes #<ISSUE_NUMBER>`.
- **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch.
- Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern.

View file

@ -35,13 +35,18 @@ jobs:
id-token: write
actions: read
steps:
# Actions are pinned to full commit SHAs rather than mutable major tags:
# this job holds ANTHROPIC_API_KEY and grants the agent Bash, so a
# force-moved tag would be an unreviewed code change inside a
# secret-holding job. The trailing `# vX.Y.Z` comment is the form
# Dependabot reads, so pinning costs us no upgrade automation. (#1882)
- name: Get PR details
if: |
(github.event_name == 'issue_comment' && github.event.issue.pull_request) ||
github.event_name == 'pull_request_review_comment' ||
github.event_name == 'pull_request_review'
id: pr
uses: actions/github-script@v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
let prNumber;
@ -57,26 +62,58 @@ jobs:
pull_number: prNumber
});
core.setOutput('sha', pr.data.head.sha);
core.setOutput('repo', pr.data.head.repo.full_name);
// A fork PR's head lives in a different repository — or in none at
// all, if the fork was deleted after the PR was opened (`head.repo`
// is then null). Neither is ours to check out, so both count as a
// fork here and the steps below decline rather than guess.
const headRepo = pr.data.head.repo?.full_name ?? null;
const isFork = headRepo !== `${context.repo.owner}/${context.repo.repo}`;
core.setOutput('sha', pr.data.head.sha);
core.setOutput('is_fork', String(isFork));
# A fork PR's head is untrusted code, and checking it out would put it in
# reach of a tool-enabled agent run holding ANTHROPIC_API_KEY. Reviewing
# the base tree instead would only trade that for a confident review of
# the wrong tree, so decline visibly and leave the reason in the run.
- name: Decline fork PR
if: steps.pr.outcome == 'success' && steps.pr.outputs.is_fork == 'true'
run: |
{
echo "### Claude Code declined this pull request"
echo
echo "The head branch lives in a fork, so its code is not checked out and Claude is not run."
echo "Push the branch to this repository and re-trigger if a review is needed."
} >> "$GITHUB_STEP_SUMMARY"
# No `repository:` — a same-repo head is all that reaches this step, and
# checkout defaults to `github.repository`, which no PR can influence.
- name: Checkout PR branch
if: steps.pr.outcome == 'success'
uses: actions/checkout@v6
if: steps.pr.outcome == 'success' && steps.pr.outputs.is_fork == 'false'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.pr.outputs.sha }}
repository: ${{ steps.pr.outputs.repo }}
fetch-depth: 0
# `skipped` means the trigger was an issue or a non-PR comment, so there
# is no head to check out and the base tree is the right one. The lookup
# having *failed* is deliberately not included: this condition carries no
# status-check function, so GitHub applies an implicit `success()` and
# skips the step after a failed prior step anyway. Spelling out `skipped`
# keeps that from having to be re-derived by the next reader.
- name: Checkout repository
if: steps.pr.outcome != 'success'
uses: actions/checkout@v6
if: steps.pr.outcome == 'skipped'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Run Claude Code
# Runs only against a tree this workflow actually checked out: the base
# repo (non-PR trigger) or a same-repo PR head. A fork PR reaches here
# with `is_fork == 'true'` and both disjuncts false, so it is skipped.
if: steps.pr.outcome == 'skipped' || steps.pr.outputs.is_fork == 'false'
id: claude
uses: anthropics/claude-code-action@v1
uses: anthropics/claude-code-action@5ef2e550a465a721f4f45e4a7d3c340c873e1dcc # v1.0.190
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}

File diff suppressed because one or more lines are too long

View file

@ -39,7 +39,7 @@ inspector/
│ ├── react/ # React hooks over the state stores
│ └── storage/ # File I/O helpers for the OAuth persist backends
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests
├── scripts/ # Root build/verify tooling (install cascade, smokes, verify-build-gate, verify-format-coverage, pack:verify)
├── scripts/ # Root build/verify tooling (install cascade, smokes, verify-build-gate, verify-format-coverage, verify-dep-lockstep, pack:verify)
├── docs/ # Task-oriented guides (v1→v2 migration, server configuration, MCP App review, launcher/config plan)
├── specification/ # Design/build specifications
├── AGENTS.md # Contribution rules for agents AND humans (see below)
@ -69,6 +69,8 @@ npm install # root install; postinstall cascades into every client
The cascade (`scripts/install-clients.mjs`) is dev-only — it exits early when the package is installed as a dependency, and the published tarball ships only each client's `build/`, so end users are unaffected. Set `INSPECTOR_SKIP_CLIENT_INSTALL=1` to skip it.
**Where a dependency is declared.** The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) live in the **root** `package.json` only — never in a client's. Node resolution walks up, so the root install is on every client's chain, and the root manifest is already what the published tarball resolves against. Declaring them per client installs a second copy that can drift from the root's, which is how two versions of `ext-apps` (and of the transitive v1 `@modelcontextprotocol/sdk`) ended up in the tree before [#1970](https://github.com/modelcontextprotocol/inspector/issues/1970) — and a second copy of `client`/`core` is the failure `vitest.shared.mts` carries a `dedupe` workaround for. The same root-only placement holds for anything reached solely through root-owned code with no manifest of its own (`test-servers/src`, `core/`), and `vitest.shared.mts` aliases those to the repo root — `express` and `yaml`, both reached through `test-servers/src`, are the two today. **Whether such a package is a `dependency` or a `devDependency` follows from who consumes it at runtime, not from where it is declared:** anything `core/` imports at runtime must be a root **`dependency`**, because the client builds externalize npm packages and a published install resolves them from the root manifest, where devDependencies are absent. `express` is test-only and is a devDependency; `yaml` currently sits in `dependencies`.
## Running during development
For day-to-day web iteration, run Vite directly from the web client (fast HMR, no launcher build needed):
@ -142,6 +144,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa
| `xmcpheader-modern-http.json` | Tools tab: `x-mcp-header` mirroring and exclusions | [#1632](https://github.com/modelcontextprotocol/inspector/issues/1632) |
| `pagination-http.json` | Page-by-page list fetching | [#1721](https://github.com/modelcontextprotocol/inspector/issues/1721) |
| `structured-output-http.json` | Tools tab: a result's `structuredContent` section | [#1908](https://github.com/modelcontextprotocol/inspector/issues/1908) |
| `duplicate-tool-names-http.json` | A `tools/list` that repeats a tool name | [#1957](https://github.com/modelcontextprotocol/inspector/issues/1957) |
| `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) |
| `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) |
| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) |
@ -216,6 +219,14 @@ Turn on **"Fetch Lists One Page at a Time"** (Server Settings — the `paginated
Run `list_items` from the Tools tab: the result panel shows the `content[]` text summary ("Found 2 items.") **and** a collapsible **Structured Output** section rendering the schema-validated payload as pretty-printed, copyable JSON. That section is what v2 was dropping — a tool declaring an `outputSchema` returns its real data there, and the text block usually only summarizes it. Run `echo` to confirm the section is absent when a result carries no `structuredContent`.
#### Duplicate tool names
`duplicate-tool-names-http.json` serves `get_weather`, `get_temp`, `echo`, and `add`, then repeats `get_weather` and `echo` at the end of `tools/list` with the same `name` and a `(duplicate)` title (`duplicateToolNames`). No preset can produce this shape — the SDK's `registerTool` rejects a repeated name — but a real server can and does, and the Inspector has to render it faithfully.
Connect (default legacy era), open the Tools tab, and type `get` into **Search tools**: the list must narrow to exactly the three `get_*` rows. On the broken build it kept a stale `echo` row, because the sidebar keyed rows by `tool.name` alone and the colliding keys orphaned a child during reconciliation ([#1957](https://github.com/modelcontextprotocol/inspector/issues/1957)).
The duplicated copies are appended rather than placed beside their twin on purpose. React matches a leading run of same-key children first, so a head-adjacent duplicate happens to line up and the defect hides; separating the pair is what makes it observable — and it is also the realistic shape, two tool sources concatenated.
#### Advertised extensions
`advertised-extensions-http.json` serves `echo` (always) and a `get_weather` tool **gated on the `io.modelcontextprotocol/tasks` extension** (`extensionGatedTools`): the tool is registered but starts disabled, and the server enables it on `notifications/initialized` only when the client declared that extension in its `capabilities.extensions`.
@ -273,13 +284,14 @@ Each client self-validates from its own folder; the root scripts chain them. The
| Script | What it does |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `npm run validate` | Runs `verify:format-coverage` (asserts every tracked source file is format-gated) first, then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui only) + `build` + fast unit tests. The quick inner-loop check. |
| `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no dependency the shared sources directly import skews across installs) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. |
| `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). |
| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. |
| `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. |
| `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. |
| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards _this_ gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. |
| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. |
| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. |
| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from what those shared sources import, compares the committed lockfiles' top-level entries, and **fails deny-by-default** on any skew not in the annotated `TOLERATED_SKEW` allowlist — and an allowlisted package is tolerated only *within a major version*. Runs in `validate`. |
| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. |
| `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). |

File diff suppressed because it is too large Load diff

View file

@ -32,10 +32,6 @@
"format:check": "prettier --check src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\""
},
"dependencies": {
"@modelcontextprotocol/client": "2.0.0-beta.5",
"@modelcontextprotocol/core": "2.0.0-beta.5",
"@modelcontextprotocol/server": "2.0.0-beta.5",
"@modelcontextprotocol/server-legacy": "2.0.0-beta.5",
"@napi-rs/keyring": "^1.3.0",
"ajv": "^8.17.1",
"atomically": "^2.1.1",
@ -43,7 +39,7 @@
"open": "^10.2.0",
"pino": "^9.14.0",
"undici": "^8.5.0",
"zod": "^4.3.6"
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",

View file

@ -8,8 +8,6 @@
"name": "@modelcontextprotocol/inspector-tui",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/client": "2.0.0-beta.5",
"@modelcontextprotocol/core": "2.0.0-beta.5",
"@napi-rs/keyring": "^1.3.0",
"ajv": "^8.17.1",
"atomically": "^2.1.1",
@ -20,7 +18,7 @@
"open": "^10.2.0",
"pino": "^9.14.0",
"react": "^19.2.4",
"zod": "^4.3.6"
"zod": "^4.4.3"
},
"bin": {
"mcp-inspector-tui": "build/index.js"
@ -1054,36 +1052,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@modelcontextprotocol/client": {
"version": "2.0.0-beta.5",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.5.tgz",
"integrity": "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg==",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/core": "2.0.0-beta.5",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"jose": "^6.1.3",
"pkce-challenge": "^5.0.0",
"zod": "^4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@modelcontextprotocol/core": {
"version": "2.0.0-beta.5",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.5.tgz",
"integrity": "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew==",
"license": "MIT",
"dependencies": {
"zod": "^4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@napi-rs/keyring": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz",
@ -2830,6 +2798,7 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@ -3247,27 +3216,6 @@
"node": ">=0.10.0"
}
},
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"license": "MIT",
"dependencies": {
"eventsource-parser": "^3.0.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/eventsource-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
"integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
@ -3793,6 +3741,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
@ -3834,15 +3783,6 @@
"node": ">=8"
}
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/joycon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
@ -4529,6 +4469,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -4608,15 +4549,6 @@
"node": ">= 6"
}
},
"node_modules/pkce-challenge": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
"license": "MIT",
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/pkg-types": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
@ -4964,6 +4896,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@ -4976,6 +4909,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -6211,6 +6145,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"

View file

@ -27,8 +27,6 @@
"format:check": "prettier --check src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\""
},
"dependencies": {
"@modelcontextprotocol/client": "2.0.0-beta.5",
"@modelcontextprotocol/core": "2.0.0-beta.5",
"@napi-rs/keyring": "^1.3.0",
"ajv": "^8.17.1",
"atomically": "^2.1.1",
@ -39,7 +37,7 @@
"open": "^10.2.0",
"pino": "^9.14.0",
"react": "^19.2.4",
"zod": "^4.3.6"
"zod": "^4.4.3"
},
"overrides": {
"ink-select-input": "^6.2.0"

File diff suppressed because it is too large Load diff

View file

@ -41,11 +41,6 @@
"@mantine/form": "^8.3.17",
"@mantine/hooks": "^8.3.17",
"@mantine/notifications": "^8.3.17",
"@modelcontextprotocol/client": "2.0.0-beta.5",
"@modelcontextprotocol/core": "2.0.0-beta.5",
"@modelcontextprotocol/ext-apps": "^1.7.4",
"@modelcontextprotocol/server": "2.0.0-beta.5",
"@modelcontextprotocol/server-legacy": "2.0.0-beta.5",
"@napi-rs/keyring": "^1.3.0",
"ajv": "^8.17.1",
"atomically": "^2.1.1",
@ -61,7 +56,7 @@
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"remark-gfm": "^4.0.1",
"zod": "~4.3.6"
"zod": "^4.4.3"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.2.1",
@ -87,7 +82,6 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-storybook": "^10.5.5",
"express": "^5.2.1",
"globals": "^17.4.0",
"happy-dom": "^20.9.0",
"playwright": "^1.58.2",

View file

@ -204,6 +204,129 @@ describe("ToolControls", () => {
expect(onRefreshList).toHaveBeenCalledTimes(1);
});
// A server may legitimately return the same tool name twice; keying rows by
// name alone collides, and React then reuses the stale row instead of
// unmounting it when the filter narrows (#1957).
const duplicateNameTools: Tool[] = [
{
name: "get_record",
title: "Get Record First",
inputSchema: { type: "object" },
},
{
name: "duplicate_tool",
title: "First Duplicate",
inputSchema: { type: "object" },
},
{
name: "unrelated_tool",
title: "Unrelated Tool First",
inputSchema: { type: "object" },
},
{
name: "duplicate_tool",
title: "Second Duplicate",
inputSchema: { type: "object" },
},
{
name: "get_record",
title: "Get Record Second",
inputSchema: { type: "object" },
},
{
name: "unrelated_tool",
title: "Unrelated Tool Second",
inputSchema: { type: "object" },
},
];
it("removes every non-matching row when tool names repeat (#1957)", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledToolControls tools={duplicateNameTools} />);
await user.type(screen.getByPlaceholderText("Search tools..."), "get");
expect(screen.getByText("Get Record First")).toBeInTheDocument();
expect(screen.getByText("Get Record Second")).toBeInTheDocument();
for (const stale of [
"First Duplicate",
"Second Duplicate",
"Unrelated Tool First",
"Unrelated Tool Second",
]) {
expect(screen.queryByText(stale)).not.toBeInTheDocument();
}
});
// The shape `test-servers/configs/duplicate-tool-names-http.json` serves: the
// repeats are appended after the whole list rather than sitting beside their
// twin. Distinct from the fixture above and worth its own case — React
// matches a leading run of same-key children first, so where the duplicates
// sit decides which row gets orphaned (#1957).
const appendedDuplicateTools: Tool[] = [
{ name: "get_weather", inputSchema: { type: "object" } },
{ name: "get_temp", inputSchema: { type: "object" } },
{ name: "echo", inputSchema: { type: "object" } },
{ name: "add", inputSchema: { type: "object" } },
{
name: "get_weather",
title: "get_weather (duplicate)",
inputSchema: { type: "object" },
},
{
name: "echo",
title: "echo (duplicate)",
inputSchema: { type: "object" },
},
];
it("removes every non-matching row when repeats are appended (#1957)", async () => {
const user = userEvent.setup();
renderWithMantine(
<ControlledToolControls tools={appendedDuplicateTools} />,
);
await user.type(screen.getByPlaceholderText("Search tools..."), "get");
expect(screen.getByText("get_temp")).toBeInTheDocument();
expect(screen.getByText("get_weather (duplicate)")).toBeInTheDocument();
expect(screen.queryByText("add")).not.toBeInTheDocument();
// The row the collision orphaned on the broken build.
expect(screen.queryByText("echo")).not.toBeInTheDocument();
expect(screen.queryByText("echo (duplicate)")).not.toBeInTheDocument();
});
it("removes every non-matching excluded row when names repeat (#1957)", async () => {
const user = userEvent.setup();
const duplicateExcluded = [
{
tool: { name: "get_thing", inputSchema: { type: "object" as const } },
reason: "a",
},
{
tool: { name: "dupe", inputSchema: { type: "object" as const } },
reason: "b",
},
{
tool: { name: "dupe", inputSchema: { type: "object" as const } },
reason: "c",
},
{
tool: { name: "get_other", inputSchema: { type: "object" as const } },
reason: "d",
},
];
renderWithMantine(
<ControlledToolControls tools={[]} excludedTools={duplicateExcluded} />,
);
await user.type(screen.getByPlaceholderText("Search tools..."), "get");
expect(screen.getByText("get_thing")).toBeInTheDocument();
expect(screen.getByText("get_other")).toBeInTheDocument();
expect(screen.queryByText("dupe")).not.toBeInTheDocument();
});
it("renders no load error by default", () => {
renderWithMantine(<ToolControls {...baseProps} />);
expect(screen.queryByText(/Couldn't load/)).not.toBeInTheDocument();

View file

@ -108,6 +108,18 @@ const ExcludedTooltip = Tooltip.withProps({
position: "right",
});
// A server may return the same tool name more than once, so the name alone is
// not a unique React key — colliding keys let a filtered-out row survive
// reconciliation instead of unmounting (#1957). The tool's position in the
// unfiltered list disambiguates duplicates and stays stable while the search
// narrows, since it is captured before filtering.
const rowKey = (name: string, sourceIndex: number) => `${sourceIndex}:${name}`;
/** Matches a tool against the (already lower-cased) search query by name or title. */
const matchesQuery = (tool: Tool, query: string) =>
tool.name.toLowerCase().includes(query) ||
(tool.title?.toLowerCase().includes(query) ?? false);
export function ToolControls({
tools,
excludedTools = [],
@ -122,22 +134,19 @@ export function ToolControls({
}: ToolControlsProps) {
const viewportRef = useScrollMemory("tools-sidebar");
const query = searchText.toLowerCase();
const filteredTools = searchText
? tools.filter(
(tool) =>
tool.name.toLowerCase().includes(query) ||
(tool.title?.toLowerCase().includes(query) ?? false),
)
: tools;
// Stamp each row's source position before filtering, so the key survives the
// list narrowing (#1957).
const filteredTools = tools
.map((tool, sourceIndex) => ({ tool, key: rowKey(tool.name, sourceIndex) }))
.filter(({ tool }) => !searchText || matchesQuery(tool, query));
// Excluded tools are searchable too, matching name AND title like the main
// list above, so a filtered view stays consistent.
const filteredExcluded = searchText
? excludedTools.filter(
({ tool }) =>
tool.name.toLowerCase().includes(query) ||
(tool.title?.toLowerCase().includes(query) ?? false),
)
: excludedTools;
const filteredExcluded = excludedTools
.map((excluded, sourceIndex) => ({
...excluded,
key: rowKey(excluded.tool.name, sourceIndex),
}))
.filter(({ tool }) => !searchText || matchesQuery(tool, query));
return (
<SidebarStack>
@ -156,9 +165,9 @@ export function ToolControls({
<ListLoadError error={loadError} what="tools" onRetry={onRefreshList} />
<SidebarScroll viewportRef={viewportRef}>
<Stack gap="xs">
{filteredTools.map((tool) => (
{filteredTools.map(({ tool, key }) => (
<ToolListItem
key={tool.name}
key={key}
tool={tool}
selected={tool.name === selectedName}
onClick={() => {
@ -169,8 +178,8 @@ export function ToolControls({
{filteredExcluded.length > 0 && (
<>
<ExcludedDivider />
{filteredExcluded.map(({ tool, reason }) => (
<ExcludedTooltip key={tool.name} label={reason}>
{filteredExcluded.map(({ tool, reason, key }) => (
<ExcludedTooltip key={key} label={reason}>
<ExcludedRow>
<ExcludedWarningIcon>
<RiErrorWarningLine />

View file

@ -515,4 +515,58 @@ describe("RemoteClientTransport", () => {
reason: "token_expired",
});
});
// #1935: the SDK Client calls setProtocolVersion() on this transport, but the
// real HTTP transport lives on the backend — so the version has to ride the
// send envelope or the backend drops `Mcp-Protocol-Version` upstream.
it("forwards the negotiated protocol version on every send after setProtocolVersion", async () => {
const seenBodies: string[] = [];
let sse = createPushableSseStream();
const fetchFn = vi
.fn<typeof fetch>()
.mockImplementation(async (input, init) => {
const url = String(input);
if (url.endsWith("/api/mcp/connect")) {
return new Response(JSON.stringify({ sessionId: "abc" }), {
status: 200,
});
}
if (url.includes("/api/mcp/events")) {
sse = createPushableSseStream();
return sse.response;
}
if (url.endsWith("/api/mcp/send")) {
seenBodies.push(String(init?.body));
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
return new Response("not found", { status: 404 });
});
const transport = new RemoteClientTransport({ baseUrl, fetchFn }, config);
await transport.start();
// Pre-initialize sends carry no version (none negotiated yet).
await transport.send({ jsonrpc: "2.0", method: "notifications/cancelled" });
expect(JSON.parse(seenBodies[0]!)).not.toHaveProperty("protocolVersion");
transport.setProtocolVersion("2025-11-25");
// The first post-initialize send is `notifications/initialized` — the one
// the reporting server rejected for a missing header.
await transport.send({
jsonrpc: "2.0",
method: "notifications/initialized",
});
await transport.send({ jsonrpc: "2.0", method: "notifications/cancelled" });
expect(JSON.parse(seenBodies[1]!)).toMatchObject({
protocolVersion: "2025-11-25",
message: { method: "notifications/initialized" },
});
expect(JSON.parse(seenBodies[2]!)).toMatchObject({
protocolVersion: "2025-11-25",
});
await transport.close();
});
});

View file

@ -0,0 +1,171 @@
import { describe, it, expect, afterEach } from "vitest";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
import {
createTestServerHttp,
type TestServerHttp,
createTestServerInfo,
createEchoTool,
createGetWeatherTool,
loadConfig,
resolveConfig,
} from "@modelcontextprotocol/inspector-test-server";
import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server";
/**
* Live coverage of `ServerConfig.duplicateToolNames` (#1957) — the only way this
* repo can serve a `tools/list` that repeats a name, since every preset
* registers a unique one and the SDK's `registerTool` rejects a repeat.
*
* The Tools sidebar keyed its rows by `tool.name`, so duplicates collided and
* filtering left an unrelated row mounted. The component-level regressions live
* in `ToolControls.test.tsx`; this file covers the server option those
* screenshots and the manual repro depend on — the wire shape, the ordering
* that makes the defect observable, and the config plumbing.
*/
describe("duplicate tool names in tools/list (#1957)", () => {
let client: InspectorClient | null = null;
let server: TestServerHttp | null = null;
afterEach(async () => {
if (client) {
try {
await client.disconnect();
} catch {
// ignore
}
client = null;
}
if (server) {
try {
await server.stop();
} catch {
// ignore
}
server = null;
}
});
async function start(config: Partial<ServerConfig>): Promise<TestServerHttp> {
const started = createTestServerHttp({
serverInfo: createTestServerInfo("duplicate-tool-names-test", "1.0.0"),
tools: [createEchoTool(), createGetWeatherTool()],
...config,
});
await started.start();
server = started;
return started;
}
async function connect(url: string): Promise<InspectorClient> {
const connected = new InspectorClient(
{ type: "streamable-http", url },
{ environment: { transport: createTransportNode } },
);
await connected.connect();
client = connected;
return connected;
}
it("emits the named tools twice, repeats appended, second copy titled", async () => {
const started = await start({ duplicateToolNames: ["echo"] });
const connected = await connect(started.url);
const { tools } = await connected.listAllTools();
// Repeats go at the END, not beside their twin. That ordering is
// load-bearing: React matches a leading run of same-key children first, so
// an adjacent duplicate lines up and the defect hides. Asserting the exact
// sequence keeps a future "tidy-up" from silently defanging the fixture.
expect(tools.map((t) => t.name)).toEqual(["echo", "get_weather", "echo"]);
// These presets carry no title, so the marker falls back to the name —
// which is what keeps the two rows distinguishable on screen.
expect(tools.at(-1)?.title).toBe("echo (duplicate)");
// Only the appended copy is marked; the originals are passed through as-is.
expect(tools[0]?.title).toBeUndefined();
expect(tools[1]?.title).toBeUndefined();
});
it("leaves the list alone when no names are given", async () => {
const started = await start({ duplicateToolNames: [] });
const connected = await connect(started.url);
const { tools } = await connected.listAllTools();
expect(tools.map((t) => t.name)).toEqual(["echo", "get_weather"]);
});
it("ignores a name that is not registered", async () => {
const started = await start({ duplicateToolNames: ["not_a_tool"] });
const connected = await connect(started.url);
const { tools } = await connected.listAllTools();
expect(tools.map((t) => t.name)).toEqual(["echo", "get_weather"]);
});
it("duplicates before paginating, so a pair straddles a page boundary", async () => {
const started = await start({
duplicateToolNames: ["echo", "get_weather"],
maxPageSize: { tools: 2 },
});
const connected = await connect(started.url);
// Four tools at a page size of two: the duplicated copies land on page 2,
// which only holds if duplication runs before the slice.
const firstPage = await connected.listTools();
expect(firstPage.tools.map((t) => t.name)).toEqual(["echo", "get_weather"]);
expect(firstPage.nextCursor).toBeDefined();
const { tools } = await connected.listAllTools();
expect(tools.map((t) => t.name)).toEqual([
"echo",
"get_weather",
"echo",
"get_weather",
]);
expect(tools.slice(2).map((t) => t.title)).toEqual([
"echo (duplicate)",
"get_weather (duplicate)",
]);
});
it("serves the shape the showcase config declares", async () => {
// Covers the JSON → ConfigFile → ServerConfig plumbing, not just the
// in-process option: a config file is how the manual repro and the
// screenshots in #1957 are produced.
const configPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../../../../test-servers/configs/duplicate-tool-names-http.json",
);
const resolved = resolveConfig(loadConfig(configPath));
expect(resolved.duplicateToolNames).toEqual(["get_weather", "echo"]);
// Let the harness pick the port instead of the config's fixed one, so this
// test can't collide with a manually-running showcase server.
const started = await start({
tools: resolved.tools,
duplicateToolNames: resolved.duplicateToolNames,
});
const connected = await connect(started.url);
const { tools } = await connected.listAllTools();
expect(tools.map((t) => t.name)).toEqual([
"get_weather",
"get_temp",
"echo",
"add",
"get_weather",
"echo",
]);
// The whole point of the fixture: filtering by "get" must be able to drop
// every non-matching row, duplicates included.
const matching = tools.filter(
(t) =>
t.name.includes("get") ||
(t.title?.toLowerCase().includes("get") ?? false),
);
expect(matching).toHaveLength(3);
});
});

View file

@ -25,6 +25,16 @@ function makeFetchEntry(
};
}
/** A structurally valid `Transport`, so a fixture needs no cast. */
function makeTransport(overrides: Partial<Transport> = {}): Transport {
return {
start: async () => {},
send: async () => {},
close: async () => {},
...overrides,
};
}
describe("RemoteSession", () => {
it("queues events before a consumer attaches and drains them on attach", () => {
const session = new RemoteSession("s1");
@ -120,6 +130,45 @@ describe("RemoteSession", () => {
expect(session.transport).toBe(transport);
});
// #1935: the browser's Client negotiates the version, so the backend has to
// be told before it can stamp `Mcp-Protocol-Version` on upstream requests.
it("applyProtocolVersion forwards a new version to the transport once", () => {
const session = new RemoteSession("s8a");
const setProtocolVersion = vi.fn();
session.setTransport(makeTransport({ setProtocolVersion }));
session.applyProtocolVersion("2025-11-25");
session.applyProtocolVersion("2025-11-25");
expect(setProtocolVersion.mock.calls).toEqual([["2025-11-25"]]);
// A renegotiated version (e.g. after a reconnect) is re-applied.
session.applyProtocolVersion("2026-07-28");
expect(setProtocolVersion).toHaveBeenLastCalledWith("2026-07-28");
});
it("applyProtocolVersion ignores an absent, non-token, or non-string version", () => {
const session = new RemoteSession("s8b");
const setProtocolVersion = vi.fn();
session.setTransport(makeTransport({ setProtocolVersion }));
session.applyProtocolVersion(undefined);
// Header injection attempt — must never reach the upstream transport.
session.applyProtocolVersion("2025-11-25\r\nX-Evil: 1");
session.applyProtocolVersion("");
// The value arrives from an unvalidated JSON body, so a non-string must be
// rejected on its type — `RegExp.test` would coerce these into a match.
session.applyProtocolVersion(123);
session.applyProtocolVersion(true);
session.applyProtocolVersion(null);
expect(setProtocolVersion).not.toHaveBeenCalled();
});
it("applyProtocolVersion is a no-op on a transport without setProtocolVersion (stdio)", () => {
const session = new RemoteSession("s8c");
session.setTransport(makeTransport());
expect(() => session.applyProtocolVersion("2025-11-25")).not.toThrow();
});
it("hasEventConsumer reflects whether a consumer is attached", () => {
const session = new RemoteSession("s9");
expect(session.hasEventConsumer()).toBe(false);

View file

@ -452,6 +452,58 @@ describe("Remote transport e2e", () => {
}
});
it("end-to-end: the negotiated Mcp-Protocol-Version reaches the upstream server after initialize (#1935)", async () => {
// The browser's SDK Client owns the initialize handshake, so its
// setProtocolVersion() lands on the *remote* transport. Without the
// forward to the backend, the backend's real streamable-HTTP transport
// never learns the version and every post-initialize request — starting
// with `notifications/initialized` — goes out without the header, which
// a stateful server may reject outright.
mcpHttpServer = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
serverType: "streamable-http",
});
await mcpHttpServer.start();
const config: MCPServerConfig = {
type: "streamable-http",
url: mcpHttpServer.url,
};
const { client, fetchRequestLogState } =
await setupRemoteAndConnect(config);
try {
await client.listTools();
const protocolVersion = client.getProtocolVersion();
expect(protocolVersion).toBeDefined();
const posts = fetchRequestLogState
.getFetchRequests()
.filter((r) => r.method === "POST");
const headerFor = (body: string): string | undefined => {
const entry = posts.find((r) => (r.requestBody ?? "").includes(body));
expect(entry).toBeDefined();
for (const [k, v] of Object.entries(entry?.requestHeaders ?? {})) {
if (k.toLowerCase() === "mcp-protocol-version") return v;
}
return undefined;
};
// The initialize POST itself predates negotiation and carries none.
expect(headerFor('"method":"initialize"')).toBeUndefined();
// Everything after it does.
expect(headerFor('"method":"notifications/initialized"')).toBe(
protocolVersion,
);
expect(headerFor('"method":"tools/list"')).toBe(protocolVersion);
} finally {
await client.disconnect();
}
});
it("end-to-end: SEP-2243 Mcp-Param-* mirroring reaches the upstream modern server (#1846)", async () => {
// A modern (2026-07-28) server whose `get_weather` tool annotates `city`
// with `x-mcp-header: "City"`. The SDK's modern handler validates the

View file

@ -37,6 +37,9 @@ export class RemoteSession {
static readonly AUTH_HTTP_ECHO_SUPPRESS_MS = 30_000;
private readonly requestWaits = new Map<string | number, RequestWait>();
private authProviderHandle: RemoteAuthProviderHandle | null = null;
private appliedProtocolVersion: string | undefined;
/** A protocol version is a dated token (`2025-11-25`, `2026-07-28`, …). */
private static readonly PROTOCOL_VERSION_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
constructor(sessionId: string) {
this.sessionId = sessionId;
@ -57,6 +60,32 @@ export class RemoteSession {
this.transport = transport;
}
/**
* Apply the client's negotiated protocol version to the upstream transport
* (#1935). The browser's SDK Client owns the `initialize` handshake, so the
* `setProtocolVersion` call it makes lands on the *remote* transport; without
* this the backend's real HTTP transport never learns the version and drops
* `Mcp-Protocol-Version` from every subsequent request.
*
* Ignores anything that isn't a plausible version token so a client can't
* push arbitrary bytes into an upstream header, and re-applies only on
* change (the value rides every `/api/mcp/send`). The value arrives from an
* unvalidated JSON body, so the type is checked rather than assumed —
* `RegExp.test` would otherwise coerce a `123` or `true` into a "matching"
* string and hand the wrong type to the transport.
*/
applyProtocolVersion(version: unknown): void {
if (
typeof version !== "string" ||
version === this.appliedProtocolVersion ||
!RemoteSession.PROTOCOL_VERSION_PATTERN.test(version)
) {
return;
}
this.appliedProtocolVersion = version;
this.transport.setProtocolVersion?.(version);
}
setEventConsumer(consumer: (event: SessionEvent) => void): void {
this.eventConsumer = consumer;
// Flush queued events

View file

@ -728,7 +728,8 @@ export function createRemoteApp(
return c.json({ error: "Invalid JSON body" }, 400);
}
const { sessionId, message, relatedRequestId, headers } = body;
const { sessionId, message, relatedRequestId, headers, protocolVersion } =
body;
if (!sessionId || !message) {
return c.json({ error: "Missing sessionId or message" }, 400);
}
@ -744,6 +745,11 @@ export function createRemoteApp(
return c.json({ ok: false, kind: "transport_error", error: errorMsg });
}
// The browser's SDK Client negotiated the version; hand it to the real
// upstream transport before the send so `Mcp-Protocol-Version` is stamped
// on this request and everything after it (#1935).
session.applyProtocolVersion(protocolVersion);
session.beginSend();
const requestId = requestIdForSendWait(message);
let responseWait: Promise<void> | undefined;

View file

@ -221,6 +221,7 @@ async function* parseSSE(
*/
export class RemoteClientTransport implements Transport {
private _sessionId: string | undefined = undefined;
private _protocolVersion: string | undefined = undefined;
private eventStreamReader: ReadableStreamDefaultReader<Uint8Array> | null =
null;
private eventStreamAbort: AbortController | null = null;
@ -250,6 +251,22 @@ export class RemoteClientTransport implements Transport {
return this._sessionId;
}
/**
* The SDK Client calls this with the negotiated protocol version as soon as
* `initialize` resolves — before it sends `notifications/initialized` — so an
* HTTP transport can stamp `Mcp-Protocol-Version` on every later request.
*
* Here the real upstream transport lives on the backend, so we can only
* record the version and forward it on the next `/api/mcp/send`, which
* applies it to the upstream transport *before* sending. Because
* `notifications/initialized` is that next send, it and everything after it
* (including the standalone SSE GET and the session DELETE, both issued by
* the upstream transport itself) carry the header (#1935).
*/
setProtocolVersion(version: string): void {
this._protocolVersion = version;
}
/**
* Reattach to an existing remote backend session after a full-page OAuth
* redirect. Opens the SSE event stream without POST /connect.
@ -707,6 +724,11 @@ export class RemoteClientTransport implements Transport {
// Forward per-send `Mcp-Param-*` headers (SEP-2243) for the backend to
// apply to the upstream send; the browser can't set them cross-origin.
...(options?.headers != null && { headers: options.headers }),
// Forward the negotiated protocol version so the backend's transport can
// stamp `Mcp-Protocol-Version` on this and every later request (#1935).
...(this._protocolVersion !== undefined && {
protocolVersion: this._protocolVersion,
}),
};
const res = await this.fetchFn(`${this.baseUrl}/api/mcp/send`, {

View file

@ -125,6 +125,12 @@ export interface RemoteSendRequest {
* them to the upstream `transport.send`, filtered to the `Mcp-Param-` prefix.
*/
headers?: Record<string, string>;
/**
* MCP protocol version negotiated by the client's `initialize`. The backend
* applies it to the upstream transport before the send so HTTP transports
* stamp `Mcp-Protocol-Version` on this and every later request (#1935).
*/
protocolVersion?: string;
}
export type RemoteEventType =

Some files were not shown because too many files have changed in this diff Show more