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:
commit
0a2b7f7179
32 changed files with 2196 additions and 2291 deletions
8
.github/copilot-instructions.md
vendored
8
.github/copilot-instructions.md
vendored
|
|
@ -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.
|
||||
|
|
|
|||
55
.github/workflows/claude.yml
vendored
55
.github/workflows/claude.yml
vendored
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
17
AGENTS.md
17
AGENTS.md
File diff suppressed because one or more lines are too long
18
README.md
18
README.md
|
|
@ -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). |
|
||||
|
||||
|
|
|
|||
1024
clients/cli/package-lock.json
generated
1024
clients/cli/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
79
clients/tui/package-lock.json
generated
79
clients/tui/package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
1077
clients/web/package-lock.json
generated
1077
clients/web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`, {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue