diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index aa1d6dd15..0bc70bfca 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -72,6 +72,14 @@ Both exist and do different jobs. Theme files (`src/theme/.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 `/node_modules` like the other pins there. +- **A dependency that renders React components must be bundled into the client that uses it, and is then not a root dependency.** An externalized package resolves its own `react` from wherever npm placed it in the *consumer's* tree, beside a React satisfying *its* peer range — looser than ours, which is all it takes to split React. `ink-form`/`ink-scroll-view` declare `">=18"`, so a consumer's React 18 satisfies them, the TUI ends up with two React instances, and it crashes on the first hook (#1952). Both are inlined via `noExternal` in `clients/tui/tsup.config.ts`. **`ink` is the one exemption, justified by cost (~1.4MB) — never by a peer range**: flag any claim that `">=19"` keeps npm from misplacing it, which is false and was in this repo once. What keeps it safe is the **root `react` range staying open to the whole major (`^19.0.0`)** so npm can dedupe with a consumer's pinned React 19; treat narrowing that range as reopening the bug. `clients/tui/__tests__/tsupConfig.test.ts` enforces the split, the root-declaration of exempt packages, and that range. +- **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. @@ -85,15 +93,18 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo ### Rendering components in tests -- **Always render through `renderWithMantine`** from `src/test/renderWithMantine.tsx`. Never hand-roll a bare `MantineProvider` — that reintroduces a real failure class where a `Transition`/`Modal` timer fires after happy-dom tears down `window` and fails the entire run. +- **Always render through `renderWithMantine`** from `src/test/renderWithMantine.tsx`. A hand-rolled bare `MantineProvider` skips the project theme and the helper's options, and drifts from every other test. (It does *not* reintroduce the timer-leak class — an older version of this rule said so; the leaked-timer net in `setup.ts` is global and covers every unit test regardless of how it renders.) - For a forced color scheme, pass the option — `renderWithMantine(ui, { colorScheme: "dark" })` — rather than a hand-rolled `defaultColorScheme` provider. - Only when asserting _mid-flight_ transition state, use `renderWithMantineTransitions`, passing `settleMs` derived from the component's real animation duration. Do **not** combine it with `vi.useFakeTimers()`, and use the `unmount()` it returns if the test unmounts the tree itself. ## 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. +- **A test or smoke must not touch real user state.** The web smokes run against a throwaway catalog via the shared `scripts/lib/prod-web-server.mjs` helper, never the developer's `~/.mcp-inspector/mcp.json` (#1977); the cli/tui smokes drive a temp `--catalog`. A new smoke spawning its own server, or teardown that removes a work dir without first awaiting `stopChild` (the #1801 race — `child-cleanup.mjs` exports both halves and both are required), should be flagged. - **Every PR references an issue**, first body line `Closes #`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. +- **Commits carry a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit; it matches the trailer against the author *or* committer, and skips only merge and bot commits. Use `git commit -s` — note `format.signOff` does *not* sign `git commit` (only `format-patch`). Repairing pushed commits means `git rebase HEAD~ --signoff` + `git push --force-with-lease`; remediation commits are not enabled on this repo. - 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. ## What to prioritize in review diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 8aa4ba71a..f68ae5275 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -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 }); + // 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('repo', pr.data.head.repo.full_name); + 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 }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 71e9ec4c9..d1763cbae 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -93,9 +93,11 @@ jobs: # CLI/web). Not part of any client's `validate`: it needs the # cli/tui/launcher bundles, which `validate` above already built # (smoke:web builds clients/web/dist on demand — #1486). smoke:web:browser - # boots the prod web bundle in headless chromium (#1615). smoke:tui - # self-skips here — the Ink TUI needs a real TTY (raw mode) that headless - # CI lacks, so its boot/render check is local-only. + # boots the prod web bundle in headless chromium (#1615); smoke:web:app + # goes further and drives connect → open app → widget ready against a + # composable MCP App server (#1859). Both reuse the chromium installed + # above. smoke:tui self-skips here — the Ink TUI needs a real TTY (raw + # mode) that headless CI lacks, so its boot/render check is local-only. run: npm run smoke - name: Run Storybook play-function tests diff --git a/AGENTS.md b/AGENTS.md index b86db3801..21061009c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,8 +89,9 @@ v2/main/ │ # in clients/web/vite.config.ts points at build/index.js │ # (not src/) so `getTestMcpServerPath()` returns a `.js` path. │ # tsconfig.test.json keeps paths pointing at src for typecheck. -├── docs/ # Task-oriented guides (mcp-server-configuration.md, -│ # mcp-app-review.md, launcher-config-consolidation-plan.md, +├── docs/ # Task-oriented guides (v1-to-v2-migration.md, +│ # mcp-server-configuration.md, mcp-app-review.md, +│ # launcher-config-consolidation-plan.md, │ # images/). Linked from the root README. ├── scripts/ # Root build/verify tooling: install-clients.mjs (the │ # postinstall cascade), the smoke-*.mjs runners, @@ -113,6 +114,24 @@ v2 is **not** an npm workspace — each client under `clients/*` keeps its own ` After installing, `npm run build` builds all clients. The launcher scripts (`npm run web` / `web:dev`) run the built launcher, so build first; for day-to-day web iteration use `cd clients/web && npm run dev`. +## Dependency placement + +**The MCP SDK packages — `@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps` — are declared in the repo-root `package.json` and nowhere else.** Do not add them to a `clients/*/package.json`: Node resolution walks up, so the root install already serves every client, and a per-client declaration installs a *second copy* that drifts from the root's. That drift is not theoretical — it put two versions of `ext-apps` (1.7.4 / 1.7.5), and of the transitive v1 `@modelcontextprotocol/sdk` (1.29.0 / 1.30.0), in the tree at once (#1970), and a second copy of `client`/`core` is exactly the failure the `dedupe` + `server.deps.inline` workaround in `vitest.shared.mts` exists for. + +The same **placement** rule covers anything reached only through **root-owned code that has no manifest of its own** (`test-servers/src`, `core/`): declare it at the root, and alias it to the **repo root** in `vitest.shared.mts` rather than to `/node_modules` like that file's other pins. `express` and `yaml` are the two today — both reached through `test-servers/src` (express by the http/oauth servers, yaml by `load-config.ts`). + +**Placement and classification are separate questions.** Which *section* of the root manifest it goes in follows from who consumes it at runtime: + +- A package `core/` imports at runtime belongs in root **`dependencies`**. The client builds bundle `core/` but externalize npm packages, so a published install resolves them from the root manifest — and devDependencies are not installed for consumers, so a runtime import parked there breaks the published package while passing every local check. +- A package only the tests, the test servers, or the build tooling need belongs in **`devDependencies`** — `express`, added there by #1970. +- `yaml` is in `dependencies` today even though its only importer is `test-servers/src/load-config.ts`. Left as-is deliberately (moving it changes what ships, which is not a docs change); if you touch it, confirm no published path reads YAML first. + +**A dependency that renders React components must be bundled into the client that uses it, and is then not a root dependency.** An externalized package resolves its own `react` from wherever npm placed **it** in the consumer's tree, and npm places a package beside a React satisfying *that package's* peer range — looser than ours in every case here, which is all it takes to split React. `ink-form` and `ink-scroll-view` declare `">=18"`, satisfied by a consumer's React 18 while our React 19 nests underneath: the bundle renders through one React, those packages call hooks on another, and the TUI crashes on the first hook (#1952). Both are inlined by `clients/tui/tsup.config.ts` (`noExternal`) and declared only in `clients/tui/package.json`, where the build resolves them — declaring an inlined package at the root would just make consumers install a second, unused copy. + +**`ink` is the single exemption, and it is justified by cost, not by safety.** Bundling it works but adds ~1.4MB (`react-reconciler` + `yoga-layout`, plus a `createRequire` banner, since inlined CJS calls `require` at runtime and esbuild's ESM interop rejects that without a real `require` in scope). **Never justify an exemption by a peer range** — `ink` briefly carried "its `">=19"` peer keeps npm honest", which is false: a consumer pinning React 19.0 satisfies `">=19"` while a narrower range of ours nests underneath. What actually makes the exemption safe is a *different* lever: the **root `react` range stays open to the whole major (`^19.0.0`)**, so npm can dedupe our React with whatever React 19 a consumer pins and an external `ink` lands on the same copy the bundle uses. Narrowing it (e.g. back to `^19.2.4`) silently reopens the crash for the renderer itself, which breaks TUI *startup*, not just its forms. `clients/tui/__tests__/tsupConfig.test.ts` enforces all of it: React-rendering deps inlined, each exempt package both external and root-declared, and the root range pinned to `ink`'s peer floor. + +The v1 SDK (`@modelcontextprotocol/sdk`) is **not** a dependency of this repo and must not become one — v2 uses the packages above. It appears in the lock files only as a `"peer": true` entry pulled in by `ext-apps`. + ## Contributing External contributions are accepted as **issues, not pull requests** — maintainers handle design and implementation through a prompt-driven workflow. @@ -120,20 +139,22 @@ If you've already built a change locally, share the **prompt** you used and scre **This applies to org members with write access too, not just outside contributors.** Having permission to push a branch is not authorization to open a PR. Pull requests against this repo are opened by the **repo maintainers** only. Anyone else — including organization members whose write access makes it technically possible — opens a **detailed issue** instead, and a maintainer takes it from there. A detailed issue means: the problem, how to reproduce it, the behavior you expected, and — if you've already prototyped a fix — the prompt you used and any screenshots, rather than a diff. -**Issues are filed through the forms in [`.github/ISSUE_TEMPLATE/`](./.github/ISSUE_TEMPLATE) — blank issues are disabled.** GitHub serves the chooser from the **default branch** only, so a form edited here on `v2/main` has no effect on the live chooser until the next milestone merge into `main` — and it cannot be previewed before then, which is why the schema notes below matter. There are two forms, **Bug report** (`1-bug_report.yml`, auto-labels `bug`) and **Feature request** (`2-feature_request.yml`, auto-labels `enhancement` **and `v2`**); `config.yml` holds the chooser's contact links. A form's `labels:` is **static** — GitHub cannot map a reporter's answer to a label — which splits the two cases: the **bug** form could target either line, so it carries a required version-line *dropdown* and a maintainer applies the matching label at triage per [Label by version](#issue-driven-work-style); the **feature** form is v2 by construction (v1 takes security fixes only and cannot receive a feature), so it needs no dropdown and declares `v2` statically. If v1 ever reopens to features, that static label is what has to change. **There is deliberately no security template**: a vulnerability report must not open a public issue, so the chooser routes it to the private advisory form as a contact link instead (see [`SECURITY.md`](./SECURITY.md)). When adding or changing a form, validate it against GitHub's issue-forms schema (`markdown` blocks take no `id` and no `validations`; `checkboxes` mark `required` per option, not under `validations`). +**Issues are filed through the forms in [`.github/ISSUE_TEMPLATE/`](./.github/ISSUE_TEMPLATE) — blank issues are disabled.** GitHub serves the chooser from the **default branch** only, so a form edited here on `v2/main` has no effect on the live chooser until the next milestone merge into `main` — and it cannot be previewed before then, which is why the schema notes below matter. There are two forms, **Bug report** (`1-bug_report.yml`, auto-labels `bug`) and **Feature request** (`2-feature_request.yml`, auto-labels `enhancement` **and `v2`**); `config.yml` holds the chooser's contact links. A form's `labels:` is **static** — GitHub cannot map a reporter's answer to a label — which splits the two cases: the **bug** form could target either line, so it carries a required version-line _dropdown_ and a maintainer applies the matching label at triage per [Label by version](#issue-driven-work-style); the **feature** form is v2 by construction (v1 takes security fixes only and cannot receive a feature), so it needs no dropdown and declares `v2` statically. If v1 ever reopens to features, that static label is what has to change. **There is deliberately no security template**: a vulnerability report must not open a public issue, so the chooser routes it to the private advisory form as a contact link instead (see [`SECURITY.md`](./SECURITY.md)). When adding or changing a form, validate it against GitHub's issue-forms schema (`markdown` blocks take no `id` and no `validations`; `checkboxes` mark `required` per option, not under `validations`). **Every PR must reference an issue. No exceptions, regardless of who opens it.** The PR body's first line is `Closes #` (see the [Issue-driven Work Style](#issue-driven-work-style) rules below). A PR with no linked issue has no board card, so the work is invisible to the project board and untracked — if you're about to open one and there's no issue yet, create the issue first. This holds for a maintainer's own one-line fix as much as for a feature. ## Project Status and Direction -* The v1/main branch currently contains the legacy version of the Inspector, which we are creating security fixes for in deprecated maintenance mode. It is **published straight from the branch** to the `v1-latest` npm dist-tag — v1 releases never pass through `main`, and v1 PRs therefore target `v1/main` directly. -* The v2/main branch currently contains the the new version of the Inspector, which is actively being developed and maintained. All new features, bug fixes, and refactors should be implemented in this branch. It acts as the **develop branch**: work accumulates here continuously and is merged into `main` at milestone releases. +- The v1/main branch currently contains the legacy version of the Inspector, which we are creating security fixes for in deprecated maintenance mode. It is **published straight from the branch** to the `v1-latest` npm dist-tag — v1 releases never pass through `main`, and v1 PRs therefore target `v1/main` directly. -* The main branch is the default branch for the repo, and it currently points to the latest v2 release. It is not a development branch, and no new features or bug fixes should be implemented here. It is only used for releases of the v2 Inspector via merge from v2/main, which is what publishes the `latest` npm dist-tag. +- The v2/main branch currently contains the the new version of the Inspector, which is actively being developed and maintained. All new features, bug fixes, and refactors should be implemented in this branch. It acts as the **develop branch**: work accumulates here continuously and is merged into `main` at milestone releases. + +- The main branch is the default branch for the repo, and it currently points to the latest v2 release. It is not a development branch, and no new features or bug fixes should be implemented here. It is only used for releases of the v2 Inspector via merge from v2/main, which is what publishes the `latest` npm dist-tag. ## Maintenance Rules ### Keep documentation files up to date + - When adding, removing, renaming, or changing the purpose of any file or folder, update the corresponding entry in the main README.md and/or the related clients/*/README.md - When the structure of the project, the tech stack, or the developer setup changes, update appropriate README.md files with the details. - When adding new commands, dependencies, or architectural patterns, update the relevant sections of appropriate README.md files as well. @@ -148,19 +169,35 @@ If you've already built a change locally, share the **prompt** you used and scre All work should be driven by items on the project board. -> **A v2 issue is not "created" until it is labeled `v2`, given a milestone, AND on board #28 with a Status *and* a Priority set.** Labeling alone is not enough — a label is a repo tag, the milestone is a release bucket, and the board is a separate org project. Applying `--label v2` does **not** add the item to the board, and adding it to the board does **not** set a Status or a Priority. All five are distinct steps; do all five (see the recipes below). **Only issues go on the board — never PRs.** A PR still gets the `v2` label, but it is tracked through its linked issue's card (via `Closes #N`), not its own board item. +> **A v2 issue _you_ create is not "created" until it is labeled `v2` **and** typed (`bug`/`enhancement`/`documentation`/`chore`/`question`), given a milestone, AND on board #28 with a Status _and_ a Priority set.** Labeling alone is not enough — a label is a repo tag, the milestone is a release bucket, and the board is a separate org project. Applying `--label v2` does **not** add the item to the board, and adding it to the board does **not** set a Status or a Priority. All five are distinct steps; do all five (see the recipes below). **Only issues go on the board — never PRs.** A PR still gets the `v2` label, but it is tracked through its linked issue's card (via `Closes #N`), not its own board item. +> +> **This describes an issue created through the flow below — not every issue that appears in the repo.** An issue opened by hand, in the GitHub UI, arrives with no label, no milestone, and no card. That is true of an outside reporter's (they have no board access) _and_ of a maintainer's (write access makes the board reachable, not automatic). Either way it is normal on arrival, not a defect to fix the moment it lands; it comes into the system through [triage](#triaging-unboarded-issues) instead. The two paths differ in exactly one thing: doing the create flow _is_ the approval, so the issue starts in **Todo** with a milestone; an issue that arrives unboarded and unmilestoned has been approved by nobody, so it starts in **Incoming** with no milestone. - Before starting work, check the board for the relevant item. - **Every board item is a real GitHub issue.** Do not create draft items (board cards with no issue number). If you find work that needs tracking, create an actual issue and add that to the board. Before creating a new issue, check the board for a matching item to avoid duplicates — **never create a duplicate**. - **Label by version — every issue and every PR, no exceptions.** Each one carries **exactly one** of `v1` or `v2` at creation. There is no unlabeled state and no "decide later": an issue with neither label belongs to no version line, appears in no version-filtered query, and is effectively invisible. - `v1` — work targeting `v1/main` (the deprecated line: security fixes only) - `v2` — work targeting `v2/main` (active development; the default for anything new) - Set the label at **create time** — `gh issue create --label v2 ...`, `gh pr create --label v2 ...` — never by backfilling later, since unlabeled items are exactly the ones missed when filtering by version. **If the target version isn't obvious, it's `v2`**: v2 is where all new work goes, and `v1` is reserved for the narrow case of patching the deprecated line. Only ask when the issue is specifically a fix *for released v1 behavior* and it's unclear whether v2 still has the bug. Note the label is a repo tag and is **not** the board — see the callout above; a `v2` issue also needs a board card with a Status **and** a Priority (a `v1` one needs a Status; board #11 has no Priority field). -- **Prioritize every new issue.** Every new issue must have a Priority (Urgent, High, Medium, or Low) set at creation time. Priority is a **board field**, not a label, so it lives on the card and an unboarded issue has nowhere to store it. Derive it with the rubric in [Setting issue priority](#setting-issue-priority) rather than asserting it — an unscored "this feels urgent" is exactly what the rubric exists to replace. + Set the label at **create time** — `gh issue create --label v2 ...`, `gh pr create --label v2 ...` — never by backfilling later, since unlabeled items are exactly the ones missed when filtering by version. **If the target version isn't obvious, it's `v2`**: v2 is where all new work goes, and `v1` is reserved for the narrow case of patching the deprecated line. Only ask when the issue is specifically a fix _for released v1 behavior_ and it's unclear whether v2 still has the bug. Note the label is a repo tag and is **not** the board — see the callout above; a `v2` issue also needs a board card with a Status **and** a Priority (a `v1` one needs a Status; board #11 has no Priority field). +- **Label by type — every issue you create or triage carries exactly one of `bug` / `enhancement` / `documentation` / `chore` / `question`.** The version label says _which line_ the work belongs to; the type label says _what kind of work it is_, and the two are independent — every issue needs both. Set it at create time (`gh issue create --label v2 --label bug ...`) or, for one arriving through [triage](#triaging-unboarded-issues), in the same pass that applies the version label. + + | Type | Use for | Not for | + | --- | --- | --- | + | `bug` | Something is broken, wrong, or regressed against its intended behavior | A missing capability that was never built | + | `enhancement` | A new capability, or extending an existing one — features, spec support, tracking issues for either | A cleanup with no behavior change | + | `documentation` | Prose deliverables — READMEs, guides, `specification/` docs, `AGENTS.md` rules | Code that happens to need a doc update | + | `chore` | Maintenance with no user-facing behavior change — dependency work, build/CI tooling, refactors, cleanup | Anything a user would notice | + | `question` | An open question or discussion with no agreed deliverable yet | Work someone has already decided to do | + + **Don't force the binary.** `bug` and `enhancement` are the two most reached for, and pressing a docs task or a dependency pin into `enhancement` degrades it to "not a bug" — at which point filtering by it stops telling you anything. If an issue is really a migration guide, say `documentation`; if it is a `tsup` → `tsdown` migration, say `chore`. + + A **PR** does not need a type label — it is classified through the issue it closes, the same way it is tracked through that issue's board card. +- **Prioritize every board item.** Every issue must have a Priority (Urgent, High, Medium, or Low) on its card — set when the card is created, whether that's at issue-creation time (yours) or at triage (an unboarded one). Priority is a **board field**, not a label, so it lives on the card and an unboarded issue has nowhere to store it. Derive it with the rubric in [Setting issue priority](#setting-issue-priority) rather than asserting it — an unscored "this feels urgent" is exactly what the rubric exists to replace. - **Add the issue to the board and set Status and Priority.** After creating an issue, add it to the board for its version — **`v2` → board #28**, **`v1` → board #11** — and set the fields. (PRs are never added to either board — they're tracked through their linked issue's card.) This is the step most easily forgotten because it needs several IDs — copy the recipes below verbatim, and take them from the section for the right board; the two projects' ids are not interchangeable. - - **New and untriaged → `Incoming`.** This is the **default status for a new item on either board.** An issue nobody has evaluated yet belongs in **Incoming**, not Todo. Todo means a maintainer approved it and it is ready to be picked up; using Todo as the inbox erases that distinction and quietly promotes unreviewed work into the queue. Anything filed by an outside reporter starts in Incoming. Work you are starting immediately goes straight to In Progress. + - **An issue you create → `Todo`.** You only file an issue for work you intend to happen, so filing it is approving it. It gets a milestone and lands in Todo, ready to be picked up. Work you are starting immediately goes straight to **In Progress** instead. + - **An issue that arrives unboarded and unmilestoned → `Incoming`.** Nobody has evaluated it yet, so it is not approved and gets no release bucket — whoever filed it. See [Triaging unboarded issues](#triaging-unboarded-issues). Never park an unreviewed issue in Todo — Todo asserts a maintainer signed off, and using it as the inbox erases that distinction and quietly promotes unreviewed work into the queue. - **Priority is v2-only.** Board #28 has a Priority field; board #11 does not. A v1 issue gets a Status and nothing else. -- **Every new issue gets a milestone — no exceptions.** Set it at create time with `gh issue create --milestone ...`. **If the user didn't specify one, default to the current milestone**: the open milestone with the nearest due date. Never leave an issue unmilestoned pending a decision — an unmilestoned issue drops out of release planning silently, the same way an unlabeled one drops out of version filtering. Moving it later is one command; noticing it was never set is the hard part. Get the current milestone with: +- **Every issue you create gets a milestone — no exceptions.** Set it at create time with `gh issue create --milestone <title> ...`, and place it in **Todo**. **If the user didn't specify one, default to the current milestone**: the open milestone with the nearest due date. Never leave an issue you filed unmilestoned pending a decision — an unmilestoned issue drops out of release planning silently, the same way an unlabeled one drops out of version filtering. Moving it later is one command; noticing it was never set is the hard part. (An issue that arrives **unboarded** is the deliberate exception: it stays unmilestoned in Incoming until a maintainer approves it — there, the *absence* of a milestone is the signal that nobody has scheduled it yet.) Get the current milestone with: ```sh # Open milestones, soonest due date first — the first row is the current one. @@ -168,58 +205,197 @@ All work should be driven by items on the project board. 'map(select(.state=="open")) | sort_by(.due_on) | .[] | "\(.title)\tdue \(.due_on[0:10])\topen=\(.open_issues)"' ``` - Milestones are **release** buckets (`v2.1.0`, `v2.2.0`, …), so pick by *when the work ships*, not by size. If a new issue plainly can't make the current milestone, say so and put it in the next one rather than leaving it blank. Sub-issues normally inherit their parent's milestone — if a sub-task must ship with its parent, they belong in the same one. + Milestones are **release** buckets (`v2.1.0`, `v2.2.0`, …), so pick by _when the work ships_, not by size. If a new issue plainly can't make the current milestone, say so and put it in the next one rather than leaving it blank. Sub-issues normally inherit their parent's milestone — if a sub-task must ship with its parent, they belong in the same one. + - When work begins, create a feature branch and set the item's Status to **In Progress**. - **Branch names start with the target version segment.** The first path segment must be the version whose base branch the PR targets — `v2/` for work on `v2/main`, `v1/` for work on `v1/main` — followed by the usual type and slug: `v2/ci/restore-claude-workflow`, `v2/fix/oauth-scope-union`, `v1/fix/proxy-ssrf-pin`. Not `ci/restore-claude-workflow`. This keeps the two lines legible in `git branch -a` and in the PR list once v1 and v2 branches coexist on the same remote, and it matches the base branches themselves (`v2/main`, `v1/main`). - When work is complete: - Run `npm run ci` from the root — the mandatory pre-push gate (see [Mandatory pre-push gate](#mandatory-pre-push-gate)). `npm run validate` is the fast inner-loop check and is **not** a substitute: it runs no coverage gate, no smokes, and no Storybook tests. + - **Sign off your commits — the DCO check is a hard merge gate.** The repo runs the [probot DCO app](https://probot.github.io/apps/dco/), which fails the PR unless each commit carries a `Signed-off-by: Name <email>` trailer whose name **and** email match either the commit's **author or its committer** (the validator checks both, so a cherry-picker's own signoff is accepted). Its only exemptions are **merge commits** and **bot-authored** commits, which it skips; everything else counts, and there is no partial credit — one unsigned commit out of six fails the whole check. The app *does* expose an override button to anyone with write access, but treat it as unavailable — see the repair bullet below for why. + - **Prevent it with `git commit -s`** — the failure is invisible until after you have pushed, so make the flag habitual. Two things that look like automation and are not: + + - ⚠️ **`git config format.signOff true` does nothing here.** Despite the name it only defaults the `-s` flag for `git format-patch`; `git commit` never reads it, and there is no `commit.signoff` equivalent. Setting it looks like a fix and silently changes nothing. + - ⚠️ **A `prepare-commit-msg` hook works, but think before installing one.** It would satisfy the check — a hook signs with your configured identity, and you are the committer, which the validator accepts. The objection is not mechanical but what it means: the trailer is a certification, and a hook makes it on your behalf for *every* commit, including work you merely cherry-picked or applied on someone else's behalf. Verified that the hook cannot distinguish those cases: inside `prepare-commit-msg`, `git var GIT_AUTHOR_IDENT` returns your config identity rather than the preserved author, so it cannot even tell that it is signing for someone else's authorship. Prefer `-s`, where each certification is a deliberate act. + + - **Repairing already-pushed commits** means rewriting them: `git rebase HEAD~<n> --signoff`, then `git push --force-with-lease`. Use `--force-with-lease` rather than `--force` so a concurrent push can't be silently clobbered, and only rewrite when you are the sole author and nobody else has based work on the branch — the usual [perils of rebasing](https://git-scm.com/book/en/v2/Git-Branching-Rebasing). **Rewriting is the only legitimate repair, and the two apparent alternatives are not.** The app's empty "remediation commit" flow requires `allowRemediationCommits.individual`; this repo ships no `.github/dco.yml`, so it runs with that disabled and the original unsigned commits keep failing. The app *does* also give anyone with write access an override button that forces the check green — but it only silences the check, it does not make the author certify anything, and the signoff is the certification (see below). Using it on your own unsigned commits asserts nothing while looking like compliance, so don't: sign the commits instead. If others are working on the branch, coordinate with them before rewriting. + - The signoff is a **[Developer Certificate of Origin](https://developercertificate.org/)** assertion, made in **your own name**. It does not claim you wrote the code — the DCO expressly covers submitting work created by others that you have the right to submit — so signing off a commit you cherry-picked is legitimate. What is never acceptable is fabricating *someone else's* certification: don't add a trailer bearing another person's name or email. If a commit needs their signoff, they add it. - Open a PR against the matching base branch (`v1/main` for v1, `v2/main` for v2) and set the item's Status to **In Review** - **Attach screenshots as proof of functionality.** Any change to the web UI or the TUI must show its result: capture before/after screenshots (or a short GIF for an interaction) and put them in a **`pr-screenshots/` folder off the repo root**, creating it if it doesn't exist. That folder is **gitignored** — the images are working artifacts staged for upload, never committed to the source tree — so attach them to the PR body from there rather than referencing an in-repo path. Name them for what they show (`tools-tab-before.png`, `tools-tab-after.png`), not `Screenshot 2026-07-31 at 14.02.11.png`. - **Link the PR to its issue — mandatory for every PR, from anyone.** No PR is opened without an issue to reference; if one doesn't exist yet, create it first (labeled and on the board) rather than opening the PR and backfilling. Note also that only the **repo maintainers** open PRs at all (see [Contributing](#contributing)) — everyone else files a detailed issue. The PR body's **first line must be `Closes #<ISSUE_NUMBER>`**. ⚠️ Note: closing keywords only auto-link/auto-close for PRs targeting the repo's **default branch** (`main`). Because v2 PRs target `v2/main` (a non-default branch), `Closes #N` there is only a cross-reference — it will **not** create a hard link or close the issue on merge. (There is no `gh` flag for manual linking — `gh pr edit` has no `--add-issue`; closing keywords are the only mechanism GitHub exposes, and they're gated to the default branch.) - **On merge of a v2 PR, manually close its issue and move the board item to Done** (option id `259d6aab`), since auto-close won't fire on `v2/main`. Keep the `Closes #N` line anyway so the issues close automatically if/when `v2/main` is eventually merged to `main`. +- **`Done` means the work shipped. An issue closed for any other reason is REMOVED from the board, not moved to Done.** Exactly two things earn a card a place in Done: + 1. Its **PR merged**, or + 2. it is a **parent whose last sub-issue closed** — the work shipped across its children rather than through one PR of its own. + + Anything else — duplicate, won't fix, not planned, obsolete, superseded — means nothing shipped, so **delete the card**: + + ```sh + ITEM_ID=$(gh project item-list 28 --owner modelcontextprotocol --format json --limit 500 \ + --jq '.items[] | select(.content.number==<ISSUE_NUMBER>) | .id') + gh project item-delete 28 --owner modelcontextprotocol --id "$ITEM_ID" + ``` + + Deleting the card removes it from the board only — **the issue itself is untouched**, keeps its labels and comments, and stays searchable and linkable forever. Nothing is lost; the board simply stops claiming the work was delivered. + + Done is read as the record of what a milestone actually delivered, so a card parked there asserts something shipped. A duplicate sitting in Done makes that record wrong in a way nobody can detect later — counting Done cards can no longer distinguish a shipped fix from a report closed as a duplicate of one. Board columns are a workflow signal; GitHub's issue list is the archive. The close **reason** is the machine-readable form of the same distinction: `gh issue close --reason` accepts only `completed` and `not planned`, so **`duplicate` must be set through the API** — `gh api repos/OWNER/REPO/issues/N -X PATCH -f state=closed -f state_reason=duplicate` — or via "Mark as duplicate" in the web UI, which additionally records a duplicate-of link. - If new tasks are discovered or requested during development, create issues and add them to the board. +### Triaging unboarded issues + +**An issue needs triage when it arrives with no board card and no milestone — regardless of who filed it.** That is the whole test, and it is deliberately about *state*, not authorship. + +It's tempting to frame this as "issues from outside reporters," but that's the wrong line. An outside reporter has no board access, so their issue necessarily lands this way — but so does a **maintainer's**, whenever they open one by hand instead of through the [documented create flow](#issue-driven-work-style). Write access makes the board *reachable*, not automatic. What puts an issue in Todo is somebody performing the approval — labeling it, milestoning it, and carding it — and an issue nobody did that for has not been approved, whoever's name is on it. + +So don't check the author's permissions; check whether the work was done. Arriving unboarded and unmilestoned is a normal state, not a defect to fix at the moment it lands — the issue enters the system through triage, in two distinct passes. + +**"Triage new issues" means running pass 1 over every unboarded open issue, then the [board audit](#the-board-audit) below.** Pass 2 is a human judgment call and is never done unprompted. + +#### Pass 1 — sweep them onto the board (no approval implied) + +For each open issue with no card: + +1. Apply the version label (`v2` unless it's a fix for released v1 behavior — see [Label by version](#issue-driven-work-style)) **and the type label** (`bug` / `enhancement` / `documentation` / `chore` / `question` — see [Label by type](#issue-driven-work-style)). An outside reporter cannot set either, so both are applied here. +2. Add it to the board for that version — **`v2` → #28, `v1` → #11**. +3. Set Status to **`Incoming`**. +4. Set Priority with the [rubric](#setting-issue-priority) (v2 only), and **record the score in a comment** (see [Recording the score](#recording-the-score)). This is an *assessment*, not an approval — it's how the queue gets ordered for the maintainer who reviews it next. +5. **Leave the milestone unset.** Nobody has committed to shipping it yet, and an empty milestone is precisely what marks it as awaiting review. + +**The one exception is an unboarded issue that already carries a milestone.** Someone recorded the approval and only the card is missing, so don't demote it to Incoming — board it straight into **Todo**, keeping the milestone. Otherwise triage would silently un-approve work a maintainer had already scheduled. + +Find the unboarded ones by diffing the open issues against **both boards**. Diffing against #28 alone is wrong: a `v1` issue correctly carded on #11 is reported as unboarded and gets double-boarded (a real defect a past sweep introduced — #1929 reproduced it). + +```sh +D=$(mktemp -d) +gh issue list --repo modelcontextprotocol/inspector --state open --limit 1000 \ + --json number,milestone > "$D/open.json" +# Union of BOTH boards, filtered to this repo — org boards can hold other repos' issues. +for P in 28 11; do + gh project item-list $P --owner modelcontextprotocol --format json --limit 700 \ + | jq '[.items[] | select(.content.type=="Issue" + and .content.repository=="modelcontextprotocol/inspector") + | .content.number]' +done | jq -s 'add' > "$D/boarded.json" +# Prints the destination too: milestoned already → Todo, otherwise → Incoming. +jq -r --slurpfile b "$D/boarded.json" \ + '.[] | select(.number as $n | ($b[0]|index($n))|not) + | "#\(.number)\t→ \(if .milestone then "Todo (has milestone \(.milestone.title))" else "Incoming" end)"' \ + "$D/open.json" +``` + +#### Pass 2 — approve what should ship + +A maintainer reads the Incoming column and, for each issue worth doing, **assigns a milestone** and moves the card to **Todo** (or **In Progress** if picking it up now). That is the whole approval gesture; see the [Status recipe](#v2-board-28-gh-recipes). + +This pass is **a judgment call reserved for a human** — deciding what ships in which release is not something to infer from a rubric. Never promote a card out of Incoming as part of a routine sweep; "triage new issues" stops at the end of pass 1. + +Issues that shouldn't ship stay in Incoming (or get closed). **Incoming is the review queue, and "milestoned" is the line between reviewed and not** — which is why the milestone stays off in pass 1 and why the rubric can treat a milestone as a real signal rather than a formality every issue carries. + +#### The board audit + +Sweeping in the unboarded issues is only the most visible defect class. A board drifts in several other ways that no single-issue rule catches, so **finish a triage run with the audit below** — every check should print `0`. Each maps to a rule stated elsewhere in this document; a non-zero count means the board contradicts the rule, not that the rule needs revisiting. + +| Check | Invariant | Fix | +| --- | --- | --- | +| Double-boarded | An issue has a card on **one** board, the one matching its version label | Delete the wrong-board card (`gh project item-delete`) | +| Non-Issue items | **Only issues go on a board** — never PRs, never drafts | Delete the item; the PR is tracked via its issue's `Closes #N` | +| No Status | Every card carries a Status | Set one — `Incoming` if unmilestoned, else by where it actually is | +| `Incoming` **with** a milestone | Incoming ⇔ no milestone | Approval was never recorded: move to **Todo**, or clear the milestone | +| Past Incoming **without** a milestone | Everything past Incoming ⇔ milestoned | Claims an approval nobody made: milestone it, or move back to Incoming | +| Wrong board for label | `v1` → #11, `v2` → #28 | Move the card to the right board | +| No version label | Every issue carries exactly one of `v1`/`v2` | Apply it (`v2` unless it's a fix for released v1 behavior) | +| No type label | Every issue carries one of `bug`/`enhancement`/`documentation`/`chore`/`question` | Classify it per [Label by type](#issue-driven-work-style) | +| No Priority (#28) | Every board item is prioritized | Score it with the [rubric](#setting-issue-priority) | +| Closed, not shipped, still carded | **Done means the work shipped** — a card closed as duplicate/not-planned is deleted, not parked | Delete the card (`gh project item-delete`) | + +```sh +D=$(mktemp -d); R=modelcontextprotocol/inspector +# --limit must exceed the repo's TOTAL issue count (884 as of 2026-08-05), not just the open ones — +# the last check below reads closed issues' state reasons. +gh issue list --repo $R --state all --limit 2000 \ + --json number,state,stateReason,labels,milestone > "$D/i.json" +for P in 28 11; do gh project item-list $P --owner modelcontextprotocol \ + --format json --limit 700 > "$D/b$P.json"; done +jq -nr --slurpfile o "$D/i.json" --slurpfile a "$D/b28.json" --slurpfile b "$D/b11.json" --arg R "$R" ' + ($o[0] | map({key:(.number|tostring), value:{st:.state, sr:(.stateReason // ""), + lab:[.labels[].name], ms:(.milestone.title // null)}}) | from_entries) as $M + | def own($s): [$s[].items[] | select(.content.repository==$R)]; + def I($n): ($M[($n|tostring)] // null); + def ms($n): (I($n).ms // null); + def lab($n): (I($n).lab // []); + def isopen($n): (I($n).st == "OPEN"); + def shipped($n): (I($n).sr == "COMPLETED"); + [own($a)[] | select(.content.type=="Issue") | {n:.content.number, s:.status, p:.priority}] as $B28 + | [own($b)[] | select(.content.type=="Issue") | {n:.content.number, s:.status}] as $B11 + | { + "double-boarded": [$B28[].n | select(. as $n | [$B11[].n]|index($n))], + "non-Issue on a board": [(own($a)[], own($b)[]) | select(.content.type!="Issue") | .content.number], + "no Status": [($B28[], $B11[]) | select(.s==null) | .n], + "Incoming w/ milestone": [($B28[], $B11[]) | select(.s=="Incoming" and ms(.n)!=null) | .n], + "past Incoming, no ms": [($B28[], $B11[]) | select(.s!=null and .s!="Incoming" and .s!="Done" + and isopen(.n) and ms(.n)==null) | .n], + "v1 label on #28": [$B28[] | select(isopen(.n) and (lab(.n)|index("v1"))) | .n], + "v2 label on #11": [$B11[] | select(isopen(.n) and (lab(.n)|index("v2"))) | .n], + "open, no version label":[$o[0][] | select(.state=="OPEN") + | select(([.labels[].name]|index("v1") or index("v2"))|not) | .number], + "open, no type label": [$o[0][] | select(.state=="OPEN") + | select(([.labels[].name] | index("bug") or index("enhancement") + or index("documentation") or index("chore") + or index("question"))|not) | .number], + "#28 open, no Priority": [$B28[] | select(.p==null and isopen(.n)) | .n], + "closed unshipped, still carded": + [($B28[], $B11[]) | select(I(.n)!=null and (isopen(.n)|not) + and (shipped(.n)|not)) | .n] + } | to_entries[] | "\(.value|length)\t\(.key)\t\(.value[0:10])"' +``` + +Two things the queries must account for, both learned the hard way: + +- **Filter by repository.** These are **org** projects and can hold cards from any repo in the org — board #11 currently carries one `modelcontextprotocol/servers` issue. Without the `.content.repository` filter it reads as a statusless-card defect and "fixing" it would mean editing another repo's tracking. +- **Only open issues have a `$I` entry.** A closed issue still has a card (correctly, in `Done`), so a check that treats "no milestone found" as a violation must gate on `open(.n)` or it flags every closed card. + +**Do not "fix" a `Done` card that is missing a milestone.** Cards predating a rule are not defects to backfill in bulk — the audit exists to stop *new* drift, and rewriting settled history destroys the record of when a rule started being enforced. + ## Setting issue priority Every issue gets a **Priority on its board card**, set when you add the issue to the board. Score it rather than assert it: rate two axes 1–5, add the signal bonuses, and read the total off the band table. The point is that two people triaging the same issue land in the same place, and that the reasoning survives in a form someone can argue with later. > ⚠️ **There are two different "Priority" fields on an issue page, and they are unrelated. Ours is the one under _Projects → Inspector V2_.** > -> | Where it appears | What it is | Ours? | -> | --- | --- | --- | -> | **Projects → Inspector V2 → Priority** | The **project board** field on board #28 (`PVTSSF_lADOCt2Azc4BJVxtzg5iJE4`). Urgent/High/Medium/Low, each option carrying its rubric band in the description. | ✅ **Yes — this is the one this rubric sets.** | -> | **Fields → Priority** (above _Projects_) | A GitHub **issue field**, `IFSS_kgDOAdAWeg`. Defined at the **`modelcontextprotocol` org** and shared by every repo in it (typescript-sdk, servers, registry, …), alongside `Effort`, `Start date`, and `Target date`. Created 2026-05-06, `ORG_ONLY`. | ❌ No. Not ours, not repo-scoped. | +> | Where it appears | What it is | Ours? | +> | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------- | +> | **Projects → Inspector V2 → Priority** | The **project board** field on board #28 (`PVTSSF_lADOCt2Azc4BJVxtzg5iJE4`). Urgent/High/Medium/Low, each option carrying its rubric band in the description. | ✅ **Yes — this is the one this rubric sets.** | +> | **Fields → Priority** (above _Projects_) | A GitHub **issue field**, `IFSS_kgDOAdAWeg`. Defined at the **`modelcontextprotocol` org** and shared by every repo in it (typescript-sdk, servers, registry, …), alongside `Effort`, `Start date`, and `Target date`. Created 2026-05-06, `ORG_ONLY`. | ❌ No. Not ours, not repo-scoped. | > > They look identical — same name, same four option names — but **nothing syncs them.** Setting one does not set the other, and they will happily disagree (this was first noticed on #1891 showing `Urgent` in Fields and `High` on the board). There is no pass-through, in either direction. > > **Never delete the org-level field.** It belongs to the whole org, so removing it would strip Priority from every other `modelcontextprotocol` repo. > -> **Don't set it either — but do read it.** A value there is a *reporter's* opinion, not a maintainer's assessment, so it is **untrusted input**. It feeds the rubric as a capped +1 signal bonus and nothing more; see [Trust boundary](#trust-boundary-who-can-set-what) below. +> **Don't set it either — but do read it.** A value there is a _reporter's_ opinion, not a maintainer's assessment, so it is **untrusted input**. It feeds the rubric as a capped +1 signal bonus and nothing more; see [Trust boundary](#trust-boundary-who-can-set-what) below. **Axis 1 — Severity / impact (1–5).** How bad is it when it happens? -| Score | Means | -| --- | --- | -| 1 | Cosmetic — a typo, a misaligned control, a wording nit. | -| 2 | Minor friction with an easy workaround. | -| 3 | A real feature is broken or missing; the workaround is annoying or partial. | -| 4 | A core workflow is unusable, or the Inspector reports something false about the server under test. | -| 5 | Data loss, a security vulnerability, or a release that is broken on arrival for everyone. | +| Score | Means | +| ----- | -------------------------------------------------------------------------------------------------- | +| 1 | Cosmetic — a typo, a misaligned control, a wording nit. | +| 2 | Minor friction with an easy workaround. | +| 3 | A real feature is broken or missing; the workaround is annoying or partial. | +| 4 | A core workflow is unusable, or the Inspector reports something false about the server under test. | +| 5 | Data loss, a security vulnerability, or a release that is broken on arrival for everyone. | **Axis 2 — Urgency / staleness (1–5).** How time-sensitive or neglected is it? -| Score | Means | -| --- | --- | -| 1 | No time pressure; nothing waits on it. | -| 2 | Wanted eventually. | -| 3 | Wanted this milestone, or has sat >90 days with no activity. | -| 4 | Blocking other work, or tied to a dated external dependency (an SDK release, a spec deadline). | -| 5 | Blocking a release, or actively hurting users on a published version right now. | +| Score | Means | +| ----- | ---------------------------------------------------------------------------------------------- | +| 1 | No time pressure; nothing waits on it. | +| 2 | Wanted eventually. | +| 3 | Wanted this milestone, or has sat >90 days with no activity. | +| 4 | Blocking other work, or tied to a dated external dependency (an SDK release, a spec deadline). | +| 5 | Blocking a release, or actively hurting users on a published version right now. | **Signal indicators (bonuses, +1 each — not an axis of their own).** These are corroborating evidence that the two axes may have undercounted, so they adjust the total rather than standing alone: - Carries a `bug` or security-related label -- Linked to a milestone +- Linked to a milestone — i.e. **already approved** by a maintainer. Note this is the *re-scoring* case: an issue being scored in a pass-1 triage has no milestone yet by rule, so it never earns this one. If you find yourself applying it to every issue in a batch, the milestone is being used as a formality rather than as approval, and the bonus has become a constant that discriminates nothing. - High engagement (many comments or reactions) - Assigned to someone - A sub-issue of a larger epic @@ -227,17 +403,36 @@ Every issue gets a **Priority on its board card**, set when you add the issue to **Bands.** Axes give 2–10 and there are six bonuses, so the total runs 2–16. -| Total | Priority | Meaning | -| --- | --- | --- | -| 12+ | **Urgent** | Drop what you're doing. | -| 9–11 | **High** | Next up after current work. | -| 6–8 | **Medium** | Scheduled normally. | -| ≤5 | **Low** | Nice to have; may sit. | +| Total | Priority | Meaning | +| ----- | ---------- | --------------------------- | +| 12+ | **Urgent** | Drop what you're doing. | +| 9–11 | **High** | Next up after current work. | +| 6–8 | **Medium** | Scheduled normally. | +| ≤5 | **Low** | Nice to have; may sit. | -Note that severity alone doesn't reach Urgent: a 5/5 with no corroborating signals totals 10 and lands **High**. That's deliberate — Urgent is reserved for a severe problem that something *else* also confirms is burning, and a band that everything qualifies for stops carrying information. Override the band when it's plainly wrong, but say why in the issue; a rubric nobody may overrule is a rubric people route around. +Note that severity alone doesn't reach Urgent: a 5/5 with no corroborating signals totals 10 and lands **High**. That's deliberate — Urgent is reserved for a severe problem that something _else_ also confirms is burning, and a band that everything qualifies for stops carrying information. Override the band when it's plainly wrong, but say why in the issue; a rubric nobody may overrule is a rubric people route around. Set the resulting level on the board card with the Priority recipe in the [V2 board (#28) `gh` recipes](#v2-board-28-gh-recipes) below. +### Recording the score + +The board stores only the *result* — a card reading `High` with no trace of how it got there. Since [the boards are private](#trust-boundary-who-can-set-what), that result is also invisible to the reporter. So **when you score an issue during triage, post the arithmetic as a comment on the issue.** Without it the rubric's promise — that the reasoning "survives in a form someone can argue with later" — is not kept by anything, and a later re-scoring has no way to tell a considered judgment from a guess. + +Keep it to the two axes, the bonuses claimed, and the total: + +```sh +gh issue comment <N> --repo modelcontextprotocol/inspector --body \ +'**Triage:** Priority **Medium** (total 7) + +- Severity 3 — a real feature is broken; workaround is partial +- Urgency 2 — wanted eventually, nothing blocked on it +- Bonuses: +1 `bug` label, +1 reporter set Fields → Priority to High + +Board: #28, Status `Incoming` (awaiting maintainer review — no milestone yet).' +``` + +Name the bonuses you claimed rather than just summing them — the milestone bonus in particular should be conspicuously absent on a pass-1 triage, and a comment that lists it is a visible sign the [approval semantics](#triaging-unboarded-issues) were misapplied. + ### Trust boundary: who can set what **The boards are private** (`public: false`, both #28 and #11 — verified 2026-08-01). The Status and Priority a maintainer assigns are visible only to people with project access: a reporter cannot see them, cannot set them, and will never learn how their issue was scored. Board priority is a maintainers' working queue, not a published commitment. @@ -250,7 +445,7 @@ That asymmetry is the whole reason the reporter's value earns a flat +1 and noth - **It cannot decide an outcome.** The bonus is capped, identical for `Urgent` and `High`, and can lift an issue at most one band. Nothing a reporter can type reaches Urgent by itself: Urgent needs 12, so the issue must already sit at 11 on maintainer-assessed axes — at which point the reporter is not the reason. - **Never map the value across.** A reporter selecting `Urgent` does **not** make the board card Urgent. Doing that would hand queue position to anyone with a GitHub account, and the queue would sort by assertiveness instead of impact. -Don't lean on GitHub's permission gate to enforce this. Whether an outside reporter can set that field today is an implementation detail that can change without notice; the rule holds either way, because it rests on *who assessed the issue* rather than on who was technically able to click. +Don't lean on GitHub's permission gate to enforce this. Whether an outside reporter can set that field today is an implementation detail that can change without notice; the rule holds either way, because it rests on _who assessed the issue_ rather than on who was technically able to click. **Assess board Priority at boarding time**, from the issue as it stands. The reporter's value is one input among several, weighted as above. @@ -259,22 +454,23 @@ Don't lean on GitHub's permission gate to enforce this. Whether an outside repor - **Repo**: https://github.com/modelcontextprotocol/inspector.git - **Base Branches** — three branches, three distinct roles. Target the one matching the work; never open a PR against `main`. - | Branch | Role | PRs target it? | Publishes to | - | --- | --- | --- | --- | - | `v2/main` | **Develop.** All active v2 work lands here. | **Yes** — every v2 PR | nothing directly; reaches npm via `main` | - | `main` | **Release.** The repo's default branch; holds the latest released v2. Not a development branch. | **No** — it only receives milestone merges from `v2/main` | `latest` | - | `v1/main` | **Maintenance.** The deprecated v1 line, security fixes only, no active development. | **Yes** — every v1 PR, directly | `v1-latest`, published straight from this branch | + | Branch | Role | PRs target it? | Publishes to | + | --------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------ | + | `v2/main` | **Develop.** All active v2 work lands here. | **Yes** — every v2 PR | nothing directly; reaches npm via `main` | + | `main` | **Release.** The repo's default branch; holds the latest released v2. Not a development branch. | **No** — it only receives milestone merges from `v2/main` | `latest` | + | `v1/main` | **Maintenance.** The deprecated v1 line, security fixes only, no active development. | **Yes** — every v1 PR, directly | `v1-latest`, published straight from this branch | So v2 flows `feature branch → v2/main → (milestone) main → npm latest`, while v1 is flat: `feature branch → v1/main → npm v1-latest`, with no merge into `main` at any point. The two lines are published independently under separate dist-tags, which is why a v1 fix does **not** need to be forward-ported to reach users on v1 (`npx @modelcontextprotocol/inspector@v1-latest`). + - **Project Boards**: - v2 - https://github.com/orgs/modelcontextprotocol/projects/28 (active board — all new work goes here) - v1 - https://github.com/orgs/modelcontextprotocol/projects/11 (legacy inspector version, no new activity except security fixes) - **Both boards start new items in `Incoming`.** A card only leaves Incoming when a maintainer has looked at it and approved the work — that is what Todo means on either board. The two boards are otherwise separate projects with their own field and option ids; never reuse one board's ids against the other (they are rejected with "option Id does not belong to the field", so the mistake is at least loud). + **On both boards, `Incoming` is the review queue for issues that arrived unboarded** — swept in at triage with a Priority but deliberately **no milestone** (see [Triaging unboarded issues](#triaging-unboarded-issues)). A card only leaves Incoming when a maintainer has looked at it and approved the work by assigning a milestone, at which time it should move to **Todo** — unless the maintainer has chosen to work on it, in which case it should move to **In Progress**. Assigning the milestone *is* the approval act, so the two always go together: a milestoned card sitting in Incoming is a card whose approval was never recorded, and a Todo card with no milestone claims an approval nobody made. An issue created through the documented flow skips Incoming entirely — doing that flow is approving it, so it starts milestoned in Todo. The two boards are otherwise separate projects with their own field and option ids; never reuse one board's ids against the other (they are rejected with "option Id does not belong to the field", so the mistake is at least loud). #### V2 board (#28) `gh` recipes -The board is an **org project**, so all commands use `--owner modelcontextprotocol` and the numeric project `28`. The project node id and the field ids are stable. **The *option* ids are NOT stable — they are regenerated whenever a single-select field's option list is edited** (see the ⚠️ hazard below). If any option id here is rejected, re-fetch the current set with: +The board is an **org project**, so all commands use `--owner modelcontextprotocol` and the numeric project `28`. The project node id and the field ids are stable. **The _option_ ids are NOT stable — they are regenerated whenever a single-select field's option list is edited** (see the ⚠️ hazard below). If any option id here is rejected, re-fetch the current set with: ```sh # Swap "Status" for "Priority" to fetch the other field's options. @@ -282,35 +478,36 @@ gh project field-list 28 --owner modelcontextprotocol --format json \ | jq '.fields[] | select(.name=="Status") | .options' ``` -| Thing | ID | -| --- | --- | -| Project node ID | `PVT_kwDOCt2Azc4BJVxt` | -| Status field ID | `PVTSSF_lADOCt2Azc4BJVxtzg5iI8c` | +| Thing | ID | +| ----------------- | -------------------------------- | +| Project node ID | `PVT_kwDOCt2Azc4BJVxt` | +| Status field ID | `PVTSSF_lADOCt2Azc4BJVxtzg5iI8c` | | Priority field ID | `PVTSSF_lADOCt2Azc4BJVxtzg5iJE4` | Status option IDs (`--single-select-option-id`) — **last verified 2026-08-01**. -| Status | Option ID | -| --- | --- | -| Incoming | `721a3d4c` | -| Todo | `fbdaf21e` | +| Status | Option ID | +| ----------- | ---------- | +| Incoming | `721a3d4c` | +| Todo | `fbdaf21e` | | In Progress | `195df262` | -| In Review | `159c8a02` | -| Done | `259d6aab` | +| In Review | `159c8a02` | +| Done | `259d6aab` | -Use **Incoming** for newly filed, untriaged work, **Todo** once a maintainer has approved it and it's ready to pick up, **In Progress** for general active work (regardless of surface), **In Review** once a PR is open, and **Done** on merge. The Incoming/Todo line is the one that matters: Todo asserts approval, so an unreviewed issue parked there is a false claim that someone signed off on it. +Use **Incoming** for an issue that arrived unboarded and is awaiting review (no milestone yet), **Todo** once a maintainer has approved it by assigning a milestone — including an issue you created through the documented flow, which starts here — **In Progress** for general active work (regardless of surface), **In Review** once a PR is open, and **Done** on merge — and *only* on merge (or a parent's last sub-issue closing). An issue closed for any other reason has its **card deleted**, because [Done asserts the work shipped](#issue-driven-work-style). The Incoming/Todo line is the one that matters: Todo asserts approval, so an unreviewed issue parked there is a false claim that someone signed off on it. The milestone is the machine-checkable form of that claim — Incoming ⇔ no milestone, everything past it ⇔ milestoned. Priority option IDs (`--single-select-option-id`) — **last verified 2026-08-01**. Derive the level with the rubric in [Setting issue priority](#setting-issue-priority); don't eyeball it. -| Priority | Option ID | Rubric total | -| --- | --- | --- | -| Urgent | `79628723` | 12+ | -| High | `0a877460` | 9–11 | -| Medium | `da944a9c` | 6–8 | -| Low | `d67ac7ce` | ≤5 | +| Priority | Option ID | Rubric total | +| -------- | ---------- | ------------ | +| Urgent | `79628723` | 12+ | +| High | `0a877460` | 9–11 | +| Medium | `da944a9c` | 6–8 | +| Low | `d67ac7ce` | ≤5 | -> ⚠️ **Never add, rename, or remove an option on a single-select board field (Status or Priority) with the `updateProjectV2Field` GraphQL mutation unless you pass every existing option's `id`.** That mutation does a **full replace** of the option list: if you resend options by name/color/description but omit their `id`s, GitHub **deletes all existing options and mints new ones**, which **orphans that field's value on every card on the board** (all items go blank for the field you edited — Status if you were editing Status, Priority if you were editing Priority) *and* invalidates every option id in that field's table above. This has happened once, on Status (required reconstructing ~197 items' statuses by inference). Safe alternatives, in order of preference: -> 1. **Add or rename an option in the GitHub web UI** (Project #28 → the field's settings). This preserves ids of untouched options and never orphans the cards on *other* options. ⚠️ **Deleting is different, in the UI as much as in the API: removing an option blanks that field's value on every card that held it, with no undo and no warning that says so.** Before deleting any option, snapshot the board (see recovery below). +> ⚠️ **Never add, rename, or remove an option on a single-select board field (Status or Priority) with the `updateProjectV2Field` GraphQL mutation unless you pass every existing option's `id`.** That mutation does a **full replace** of the option list: if you resend options by name/color/description but omit their `id`s, GitHub **deletes all existing options and mints new ones**, which **orphans that field's value on every card on the board** (all items go blank for the field you edited — Status if you were editing Status, Priority if you were editing Priority) _and_ invalidates every option id in that field's table above. This has happened once, on Status (required reconstructing ~197 items' statuses by inference). Safe alternatives, in order of preference: +> +> 1. **Add or rename an option in the GitHub web UI** (Project #28 → the field's settings). This preserves ids of untouched options and never orphans the cards on _other_ options. ⚠️ **Deleting is different, in the UI as much as in the API: removing an option blanks that field's value on every card that held it, with no undo and no warning that says so.** Before deleting any option, snapshot the board (see recovery below). > 2. If you must script it, first `gh api graphql` the current options **with their `id`s**, then call `updateProjectV2Field` echoing back every existing option **including its `id`**, appending only the new one. `ProjectV2SingleSelectFieldOptionInput.id` is an optional `String`, so a mixed list works: echo the `id` for every option that already exists, omit it only for the one being added. Verify afterward that no card lost its value — snapshot `gh project item-list … --format json` before and after and diff, don't just spot-check. > > Both the `Incoming` Status option and the Urgent/High/Medium/Low `Priority` options were added this way (#1891), with the before/after diff confirming all 264 cards kept their Status. @@ -329,7 +526,7 @@ Priority option IDs (`--single-select-option-id`) — **last verified 2026-08-01 > > This has now happened twice — once via the API (~197 items, reconstructed by inference) and once via the UI (the `Done` column, 247 items, restored from a snapshot in minutes). With a snapshot the recovery is mechanical. > -> **The recipe below is written for a deleted *Status* option** — it reads `.status` and writes the Status field id. For a deleted **Priority** option it is the same three steps with two substitutions: read `.priority` instead of `.status` (`gh project item-list --format json` exposes each single-select field under its lowercased name, so both keys are present), and pass the Priority field id `PVTSSF_lADOCt2Azc4BJVxtzg5iJE4` instead of the Status one. Everything else — the snapshot, the grouping safety check, the new-id caveat — applies unchanged. +> **The recipe below is written for a deleted _Status_ option** — it reads `.status` and writes the Status field id. For a deleted **Priority** option it is the same three steps with two substitutions: read `.priority` instead of `.status` (`gh project item-list --format json` exposes each single-select field under its lowercased name, so both keys are present), and pass the Priority field id `PVTSSF_lADOCt2Azc4BJVxtzg5iJE4` instead of the Status one. Everything else — the snapshot, the grouping safety check, the new-id caveat — applies unchanged. > > ```sh > # 1. Which cards lost their value, and what did they hold? @@ -364,16 +561,26 @@ gh project item-edit \ --single-select-option-id 195df262 ``` -The full one-liner for a **new** issue — add it, then set Status and Priority (both are required; here Incoming + Medium): +The full one-liner for an issue **you just created** — add it, then set Status and Priority (both required). It goes to **Todo**, because filing it was the approval, and it already carries the milestone you passed to `gh issue create`: ```sh ITEM_ID=$(gh project item-add 28 --owner modelcontextprotocol --url <issue-url> --format json --jq '.id') -# Status → Incoming -gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id 721a3d4c +# Status → Todo (an issue you filed is approved by definition) +gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id fbdaf21e # Priority → Medium gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iJE4 --single-select-option-id da944a9c ``` +For an issue **swept in at triage** (arrived unboarded, no milestone), the only difference is the Status option — **Incoming** (`721a3d4c`) instead of Todo — and that you do **not** set a milestone: + +```sh +ITEM_ID=$(gh project item-add 28 --owner modelcontextprotocol --url <issue-url> --format json --jq '.id') +# Status → Incoming (awaiting maintainer review; no milestone yet) +gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id 721a3d4c +# Priority → Medium (an assessment for queue ordering, not an approval) +gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iJE4 --single-select-option-id da944a9c +``` + Each `item-edit` sets **one** field, so setting both takes two calls — there is no combined form. For an issue **already on the board** (moving an existing card, e.g. to **In Review** when its PR opens, or re-scoring its Priority), look its item id up by issue number instead of re-adding it. Keep `--limit` above the board's item count (~265 as of 2026-08-01) — past it `item-list` truncates silently, `select` matches nothing, and `item-edit --id ""` fails with an opaque node-resolution error rather than saying the limit was too low: @@ -388,34 +595,37 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i #### V1 board (#11) `gh` recipes -The v1 line takes **security fixes only**, so this board sees little traffic — but a v1 issue still gets a card, and it starts in **Incoming** like a v2 one. Board #11 is a separate org project with **its own ids**; none of the #28 ids above work here. +The v1 line takes **security fixes only**, so this board sees little traffic — but a v1 issue still gets a card, and the same Incoming/Todo split applies: one created through the documented flow starts in **Todo**, one that arrived unboarded starts in **Incoming** awaiting review. Board #11 is a separate org project with **its own ids**; none of the #28 ids above work here. -| Thing | ID | -| --- | --- | -| Project node ID | `PVT_kwDOCt2Azc4BA5sz` | +| Thing | ID | +| --------------- | -------------------------------- | +| Project node ID | `PVT_kwDOCt2Azc4BA5sz` | | Status field ID | `PVTSSF_lADOCt2Azc4BA5szzgzkS-g` | Status option IDs — **last verified 2026-08-01**. -| Status | Option ID | -| --- | --- | -| Incoming | `831820cf` | -| Todo | `f75ad846` | +| Status | Option ID | +| ----------- | ---------- | +| Incoming | `831820cf` | +| Todo | `f75ad846` | | In Progress | `47fc9ee4` | -| In Review | `0439b2bf` | -| Done | `98236657` | +| In Review | `0439b2bf` | +| Done | `98236657` | There is **no Priority field on this board** — the priority rubric applies to v2 only. Don't try to set one here; the field id doesn't exist. ```sh -# Add a v1 issue to board #11 and put it in Incoming. +# Add a v1 issue to board #11. Swap the option id for the case you're in: +# 831820cf = Incoming — arrived unboarded, awaiting review (no milestone) +# f75ad846 = Todo — created through the documented flow (milestoned at create time) ITEM_ID=$(gh project item-add 11 --owner modelcontextprotocol --url <issue-url> --format json --jq '.id') -gh project item-edit --project-id PVT_kwDOCt2Azc4BA5sz --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BA5szzgzkS-g --single-select-option-id 831820cf +gh project item-edit --project-id PVT_kwDOCt2Azc4BA5sz --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BA5szzgzkS-g --single-select-option-id f75ad846 ``` -The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe above apply to **this board too** — same mutation, same failure mode, different ids. Note that three cards on #11 already carry no Status; that predates the `Incoming` addition (verified by before/after diff on 2026-08-01) and is not evidence of an orphaning event. +The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe above apply to **this board too** — same mutation, same failure mode, different ids. Note that board #11 also carries **one card from another repo** (`modelcontextprotocol/servers`), which has no Status. That is why every audit query filters on `.content.repository` — an org project can hold items from any repo in the org, and an unfiltered check reports that card as a statusless-card defect. It is not ours to fix. (The three *inspector* cards that carried no Status on 2026-08-01 predated the `Incoming` addition and have since been set; neither case was evidence of an orphaning event.) ### Always test new or modified code + - Ensure all code has corresponding tests - Ensure test coverage for each file is at least 90% - In unit tests that expect error output, suppress it from the console @@ -433,36 +643,43 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - **TUI** (`clients/tui`): the gate now covers **all of `src/**`, React surface included** — the former interim exclusion of the Ink components, `App.tsx`, and `hooks/` was lifted in #1501. Components mount through `ink-testing-library` with the `ink-scroll-view` / `ink-form` passthrough doubles in `__tests__/helpers/`, `App.tsx` mounts against a controllable mock of the `@inspector/core` surface, and keypresses are driven through stdin. The **only** coverage exclusion left in `clients/tui/vitest.config.ts` is `src/tui-servers.ts` — a pure re-export + type alias of core's server resolver with no runtime statements of its own (the logic is measured in `core/` via the web suite; `tui-servers.test.ts` still exercises it behaviorally, and it's excluded only so it doesn't surface as a misleading 0/0 row). Any new logic under `clients/tui/src`, React or not, is held to the gate automatically. - Run `npm run test:integration` (also from `clients/web/`) for the InspectorClient + transport + auth integration suite. It runs under a separate `integration` vitest project in node env (no happy-dom) with 30s timeouts. The script builds `test-servers/` first via `tsc -p ../../test-servers --noCheck` so the stdio MCP test server can be spawned as a real subprocess. CI does not run `test:integration` as its own step — the integration project is covered by the CI `coverage` gate, whose web `test:coverage` runs `--project=unit --project=integration --coverage`. - Test files live alongside the source as `<Name>.test.tsx` (or `.test.ts` for non-React modules). Integration tests live under `clients/web/src/test/integration/`, mirroring the `core/` source layout (`mcp/`, `mcp/node/`, `mcp/remote/`, `auth/`, `auth/node/`, `storage/`). Any test file under that folder is automatically picked up by the `integration` vitest project (node env, 30s timeouts) via the folder glob in `vite.config.ts` — placement is the manifest, there is no enumeration to keep in sync. Tests outside the folder run in the `unit` project (happy-dom). When adding a new test for, e.g., `core/mcp/remote/foo.ts`, put it at `src/test/integration/mcp/remote/foo.test.ts`. -- **Test placement: side-by-side by default, `src/test/` only for what can't be co-located.** These look like competing conventions but aren't — the split is: *tests live beside their source, **except** tests for the repo-root `core/` package (which lives outside `clients/web/`) and shared test scaffolding — both of which live under `src/test/`, with `core/` tests mirroring the `core/` layout and integration tests under `src/test/integration/`.* +- **Test placement: side-by-side by default, `src/test/` only for what can't be co-located.** These look like competing conventions but aren't — the split is: _tests live beside their source, **except** tests for the repo-root `core/` package (which lives outside `clients/web/`) and shared test scaffolding — both of which live under `src/test/`, with `core/` tests mirroring the `core/` layout and integration tests under `src/test/integration/`._ - **Side-by-side (`<Name>.test.tsx` next to the source) — the default for web's own `src/` code.** Components, hooks, `lib/`, `utils/`. This is the overwhelming majority; a web-owned test living under `src/test/` instead of beside its source is a bug (fixed one such straggler, `downloadFile.test.ts`, in #1776). - - **`src/test/` — the three things that *cannot* be co-located:** (1) tests of the repo-root **`core/`** package (`src/test/core/…`, mirroring the `core/` folder layout — `core/` physically lives at `/core` outside `clients/web/`, is consumed via the `@inspector/core` alias, and has no test harness of its own, so co-locating would pollute the shared isomorphic package with web-only test infra); (2) the **`integration`** vitest project (`src/test/integration/…`, node env, 30s — placement *is* the manifest, see above); (3) **shared test infrastructure** (`renderWithMantine.tsx`, `setup.ts`, `fixtures/`, `scrollAreaStoryAssertions.ts`) — not tests *of* a source file, so nothing to sit beside. + - **`src/test/` — the three things that _cannot_ be co-located:** (1) tests of the repo-root **`core/`** package (`src/test/core/…`, mirroring the `core/` folder layout — `core/` physically lives at `/core` outside `clients/web/`, is consumed via the `@inspector/core` alias, and has no test harness of its own, so co-locating would pollute the shared isomorphic package with web-only test infra); (2) the **`integration`** vitest project (`src/test/integration/…`, node env, 30s — placement _is_ the manifest, see above); (3) **shared test infrastructure** (`renderWithMantine.tsx`, `setup.ts`, `fixtures/`, `scrollAreaStoryAssertions.ts`) — not tests _of_ a source file, so nothing to sit beside. - **The above is web only.** The Node clients (**cli, tui, launcher**) keep **all** their tests in a top-level **`__tests__/`** dir, not beside their source — their `tsconfig.json` excludes `**/*.test.*` and their `tsconfig.test.json` includes `__tests__/**/*` (plus, for launcher, its root `vitest.config.ts`), so a co-located `src/**/*.test.*` lands in **no** tsconfig project and fails `npm run verify:typecheck-coverage` (#1791). Put a new cli/tui/launcher test under `__tests__/`. -- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme. It sets `env="test"` so Mantine renders transitions synchronously (no internal `setTimeout`); this prevents a `Transition`/`Modal` timer from firing after happy-dom tears down `window` at end-of-run and failing the whole run with an uncaught `ReferenceError: window is not defined` (#1760). **Always render through `renderWithMantine`; do not hand-roll a bare `MantineProvider` in a test** (that reintroduces the leak class). To exercise a **forced color scheme** (e.g. the `useComputedColorScheme` dark branch) pass the `colorScheme` option — `renderWithMantine(ui, { colorScheme: "dark" })` — instead of hand-rolling a `defaultColorScheme="dark"` provider (#1786). Only when a test must assert *mid-flight* transition state (e.g. a `data-anim="out"` cell during an exit crossfade) use `renderWithMantineTransitions` (real transitions). Such a test can leak the #1760 class because waiting for one cell to unmount does **not** settle a concurrent *enter* (a completed enter leaves no DOM signal to `waitFor`), so the helper **automatically drains the in-flight animation after the test**. The rule for using it: pass `settleMs` derived from the component's real animation duration — its `Transition` `duration`/`exitDuration` plus any `enterDelay`/`exitDelay` plus rAF slack — e.g. `renderWithMantineTransitions(ui, { settleMs: HEADER_ANIM_MS + 200 })` (so the window can't silently become insufficient when that duration changes); do **not** also use `vi.useFakeTimers()` in the same test (the auto-settle no-ops under fake timers — it warns, but anything the test left pending on the *real* clock is then unprotected, so the test depends on which clock was installed at teardown); and if the test unmounts the tree itself, use the `unmount()` the helper returns (it drops that tree from the settle's liveness check, while still draining — a bare mid-body `cleanup()` on a still-armed tree would trip the check). The mechanism behind all three — why the drain is `act`-wrapped, the fake-timer hazard, the `afterEach`-before-`cleanup()` ordering and its `container.isConnected` self-checks, and the exported `settleTransitions(ms)` for manual mid-body settling — is documented at length on the helper in `renderWithMantine.tsx`; read there before changing it. +- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme. It sets `env="test"`, which makes Mantine skip the transition's animated **render** — but be clear on what that does *not* buy you: it does **not** stop the timers. `env` is read only by `Transition.mjs`, at its render branch (`transitionDuration === 0 || env === "test"`), while `useTransition()` runs before that check (hooks cannot be conditional) and still schedules real `window.setTimeout`s on every `mounted` change. Measured: opening a `<Modal>` through `renderWithMantine` schedules three 200ms timers (#1984). A timer that outlives its file fires after happy-dom disposes that file's `window`, and React's `dispatchSetState` then throws an uncaught `ReferenceError: window is not defined` that fails the **whole run** — attributed to whichever file was running, so it lands on an innocent one (#1760). What actually prevents that is the **leaked-timer safety net in `src/test/setup.ts`**, which tracks every timer and clears whatever is still pending after `cleanup()`; see the comment there for the rAF race that makes Mantine's own unmount cleanup insufficient. **Always render through `renderWithMantine`; do not hand-roll a bare `MantineProvider` in a test** — a hand-rolled provider skips the project theme and the helper's options and drifts from every other test. Note this rule used to be justified by the leak class, which is now wrong: the `setup.ts` net is global and covers every unit test however it renders. The rule stands on consistency, not on timer safety. To exercise a **forced color scheme** (e.g. the `useComputedColorScheme` dark branch) pass the `colorScheme` option — `renderWithMantine(ui, { colorScheme: "dark" })` — instead of hand-rolling a `defaultColorScheme="dark"` provider (#1786). Only when a test must assert _mid-flight_ transition state (e.g. a `data-anim="out"` cell during an exit crossfade) use `renderWithMantineTransitions` (real transitions). Such a test can leak the #1760 class because waiting for one cell to unmount does **not** settle a concurrent _enter_ (a completed enter leaves no DOM signal to `waitFor`), so the helper **automatically drains the in-flight animation after the test**. The rule for using it: pass `settleMs` derived from the component's real animation duration — its `Transition` `duration`/`exitDuration` plus any `enterDelay`/`exitDelay` plus rAF slack — e.g. `renderWithMantineTransitions(ui, { settleMs: HEADER_ANIM_MS + 200 })` (so the window can't silently become insufficient when that duration changes); do **not** also use `vi.useFakeTimers()` in the same test (the auto-settle no-ops under fake timers — it warns, but anything the test left pending on the _real_ clock is then unprotected, so the test depends on which clock was installed at teardown); and if the test unmounts the tree itself, use the `unmount()` the helper returns (it drops that tree from the settle's liveness check, while still draining — a bare mid-body `cleanup()` on a still-armed tree would trip the check). The mechanism behind all three — why the drain is `act`-wrapped, the fake-timer hazard, the `afterEach`-before-`cleanup()` ordering and its `container.isConnected` self-checks, and the exported `settleTransitions(ms)` for manual mid-body settling — is documented at length on the helper in `renderWithMantine.tsx`; read there before changing it. ### Responding to Code Reviews + - When asked to respond to a code review of a PR, - it is not necessary to implement all suggestions - you are free to implement suggestions in a different way or to ignore if there is a good reason - after making the changes, respond to each review comment with what was done (or why it was ignored) ### Mandatory pre-push gate + - ALWAYS do `npm run format` before committing — the **root** `format` auto-fixes `core/` (`format:core`), the root `scripts/` tooling (`format:scripts`), the root "shared" surface (`format:shared` — `test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`), and every client's scope in one shot. Every **client** format glob uses the uniform extension set `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (#1792) so a new-extension file can't slip the gate; `core/` stays `{ts,tsx}` and the shared surface `{ts,tsx,mts,cts}` (their surfaces can't hold the other extensions), and `npm run verify:format-coverage` (the first step of `validate`, #1792) is the backstop — it fails if any tracked source file is left uncovered by a `format:check` glob regardless of which glob was expected to catch it. `validate` runs `format:check` (the non-fixing variant, including `format:check:core`, `format:check:scripts`, and `format:check:shared`) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. - **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). -- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`test:scripts`** (the guard's own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). +- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`verify:dep-lockstep`** (the #1896 guard — asserts no dependency *directly imported* by the shared sources resolves to two different versions across installs; transitive-only declarations are a known boundary, #1965), then **`test:scripts`** (the guards' own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn<T>()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. It asserts the same of **`test:scripts`** — its own parser tests — on three axes: reachable from the root `validate`, a **non-empty** tracked `scripts/**/*.{test,spec}.*` set, and **every one of those files matched by a glob harvested across the scripts reachable from `test:scripts`** (so a delegating `test:scripts` still measures correctly). The third axis exists because `node --test` silently *skips* a file its glob misses and still exits 0 — a rename to `*.spec.mjs` would shrink the suite with a green run. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the *global* union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). + - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib _resolution_ options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. + - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn<T>()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is _wired_ (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. It asserts the same of **`test:scripts`** — its own parser tests — on three axes: reachable from the root `validate`, a **non-empty** tracked `scripts/**/*.{test,spec}.*` set, and **every one of those files matched by a glob harvested across the scripts reachable from `test:scripts`** (so a delegating `test:scripts` still measures correctly). The third axis exists because `node --test` silently _skips_ a file its glob misses and still exits 0 — a rename to `*.spec.mjs` would shrink the suite with a green run. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the _global_ union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. + - **One version per install-crossing dependency (#1896).** Because v2 is not a workspace, the root and each `clients/*` carry their own `node_modules` — and a client's `tsconfig.test.json` compiles first-party sources that live *outside* the client (`test-servers/src`, `core/`), which resolve their dependencies from the **root** install while the client's own sources resolve from the client install. So the same package can appear **twice in one `tsc` program**. At the same version that duplication is harmless; on a skew, TypeScript must relate two structurally-distinct declarations of the same type. For a deeply recursive-generic surface that is exponential: zod `4.3.6` (root) against zod `4.4.3` (`clients/web`) made `clients/web`'s `tsc -b` exhaust the 4GB default heap outright via `TS2589 Type instantiation is excessively deep`, because every `@modelcontextprotocol/*` schema is built out of zod generics. **Raising the heap with `--max-old-space-size` hides this class rather than fixing it — align the versions instead.** `npm run verify:dep-lockstep` (`scripts/verify-dep-lockstep.mjs`, in `validate`) is the durable guard: it **derives** the candidate set from the packages the shared first-party TypeScript imports — `core/`, `test-servers/src`, and the individually-named `vitest.shared.mts` (root-owned and imported by every client's vitest config) — so a new shared dependency is covered without editing the guard. It reads the committed lockfiles' **top-level** `node_modules/<pkg>` entries — a *nested* transitive duplicate inside one install is routine and deliberately ignored — and fails, **deny-by-default**, on any candidate held at two versions across installs. The escape hatch is `TOLERATED_SKEW` in that file, an allowlist of *names* (not version pairs, so an ordinary patch float doesn't churn it), each entry carrying why that package's types can't blow up; `react`, `hono`, `jose`, and `@modelcontextprotocol/ext-apps` are listed today. **Being listed is not a blanket exemption** — it tolerates skew only *within a major version*, since a rationale about patch-level differences says nothing about a React 18-vs-19 split, where the type surface itself changes; a cross-major skew fails even for a listed package. **When bumping a dependency that the shared sources import, bump it in every install that declares it** — that's the root plus whichever clients list it, not all four unconditionally (launcher declares no zod, for instance, and a package absent from an install can't skew, so the guard ignores it there). Don't add a dependency to a client just to satisfy this. Its pure helpers are unit-tested via `test:scripts`, and it vouches — with `verify:format-coverage` and `verify:typecheck-coverage` — that its siblings are still wired into `validate`. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. -- **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). +- **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser` / `smoke:web:app`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. -- `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/web/dist`, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (`build:client` = `vite build`) on first launch, or exits with an actionable error if that build can't run (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`. It shares the spawn/readiness/teardown helper (`scripts/lib/prod-web-server.mjs`) with `smoke:web:browser`, so the two can't drift. -- `smoke:web:browser` (`scripts/smoke-web-browser.mjs`, #1615) goes a step further than `smoke:web`: it boots the same prod `--web` server and then actually **runs** the bundle in headless Chromium (Playwright — already a `clients/web` devDependency for the Storybook tests), asserting the app renders its first meaningful frame (the "Add Servers" control) with **no uncaught error**. `smoke:web` only checks the served HTML, so a Node built-in reaching the browser bundle slipped through it; this smoke catches that regression as a *class* (e.g. #1612). The mechanism is the uncaught error, not a magic string: under Vite the excluded module becomes an empty stub and the first *call* into it (e.g. `fs.readFileSync(...)` during a transitive module's init) throws a `TypeError` that aborts app mount. A *synchronous* such throw fires `pageerror`; its *async* twin (the same `TypeError` via `await`/`.then()`, or a failed dynamic import) is logged on the console channel as `Uncaught (in promise) …` / `Failed to fetch dynamically imported module` — the smoke hard-fails on both. The literal `Module "…" has been externalized` text is, **in a prod build**, a build-time warning (`vite build` / `npm run build`), not a runtime message, so the browser never sees it (under `npm run dev` Vite's stub is instead a `Proxy` that `console.warn`s that string at runtime); and an externalized import that is never *called* ships a harmless `{}` and is invisible here by design. Every *other* console error is printed as a diagnostic, not a failure (so a benign font-CDN or React-warning `console.error` doesn't flake CI). Playwright is resolved via `createRequire` based at `clients/web/package.json` — a bare `import("playwright")` would resolve relative to `scripts/`, not the cwd, so it can't be reached that way (it only appears to work when an ancestor `node_modules` carries playwright, and fails in CI, which has none). The npm script's `cd clients/web` exists only so `npx playwright install chromium` finds the local playwright bin (a no-op when already installed). -- **The build gate for the browser-externalized-builtin class (#1769)** is the earlier, more complete companion to `smoke:web:browser`. A Vite plugin in `clients/web/vite.config.ts` (logic in `clients/web/server/browser-externalized-builtin-gate.ts`, unit-tested) turns Vite 8's *browser-externalization warning* (`Module "node:*" has been externalized for browser compatibility`) into a hard `vite build` error, so a Node built-in in the browser graph now **fails `npm run build` / `validate`** instead of shipping a `{}` stub. This catches **both** the *called-at-init* case (which `smoke:web:browser` also catches, but later/at runtime) **and** the *imported-but-never-called* case (the `{}` stub that is invisible to the runtime smoke "by design" — see above). Because rolldown **swallows a throw inside `onLog`** (the one hook where a thrown error doesn't abort — verified against vite@8.0.0), the plugin *records* the warning in `onLog` and re-throws in `buildEnd`. There is **no stable log `code`**, so the gate keys off the documented message phrasing; `npm run verify:build-gate` (`scripts/verify-build-gate.mjs`, in `npm run ci` and the GitHub workflow) runs a real build with a `node:fs` probe forced into `src/main.tsx` and asserts the build fails via the gate — the only check that catches the message phrasing **drifting** in a future Vite bump and silently disabling the gate. The gate is scoped to `vite build` (`apply: 'build'`) — never `vite dev` or the vitest projects — **and** to the browser (`client`) environment (`applyToEnvironment`), so a future SSR/node environment built from this config isn't failed for a legitimate `node:*` import; the Node runner build (tsup, `build:runner`) is a separate config where built-ins are legitimate. `smoke:web:browser` stays as the runtime backstop for crashes the build can't reason about. +- `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/web/dist`, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (`build:client` = `vite build`) on first launch, or exits with an actionable error if that build can't run (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`. It shares the spawn/readiness/teardown helper (`scripts/lib/prod-web-server.mjs`) with **`smoke:web:browser` and `smoke:web:app`**, so the three can't drift. + + **Every web smoke runs against a throwaway catalog (#1977).** The helper mints a temp dir per run and passes it as `MCP_CATALOG_PATH`; without it the web backend falls back to the developer's real `~/.mcp-inspector/mcp.json`, which made these smokes both destructive and non-deterministic — `smoke:web:app`'s deep link persists a `deep-link` server row, so a *second* run found it already on disk, raced hydration, and drew a spurious (swallowed, non-fatal) 409 that was really just residue from the previous run. CI never saw it: a fresh `HOME` per run made every CI run look like a first run. This matches `smoke:cli` / `smoke:tui`, which have always driven a temp `--catalog`. Only the **catalog** is redirected — other per-user state under `~/.mcp-inspector` (OAuth tokens, `storage/`) stays shared, because isolating it means redirecting `HOME` wholesale, which would also move the npx and Playwright caches these smokes depend on. Teardown uses **both** halves of `scripts/lib/child-cleanup.mjs` (`stopChild` to await the child's exit, then `removeSafe` to delete the dir) — a bare `kill()` only *delivers* the signal, so removing synchronously re-enters the #1801 ENOTEMPTY race. That makes `stop()` **async**, so every caller must `await` it (and a caller's own `fail()`/`shutdown()` becomes async in turn, or execution runs past the intended exit). The isolation contract is unit-tested in `scripts/lib/prod-web-server.test.mjs` via `test:scripts`, since the smokes exit immediately after teardown and so cannot detect a regression that silently reshared the catalog or stopped cleaning up: `createTempCatalog` and `buildWebServerEnv` cover *which* catalog the server gets, and `teardownWebServer` — extracted from `stop()` for exactly this reason — is driven against a stand-in child process so the teardown asserts on the real directory rather than a spy. +- `smoke:web:browser` (`scripts/smoke-web-browser.mjs`, #1615) goes a step further than `smoke:web`: it boots the same prod `--web` server and then actually **runs** the bundle in headless Chromium (Playwright — already a `clients/web` devDependency for the Storybook tests), asserting the app renders its first meaningful frame (the "Add Servers" control) with **no uncaught error**. `smoke:web` only checks the served HTML, so a Node built-in reaching the browser bundle slipped through it; this smoke catches that regression as a _class_ (e.g. #1612). The mechanism is the uncaught error, not a magic string: under Vite the excluded module becomes an empty stub and the first _call_ into it (e.g. `fs.readFileSync(...)` during a transitive module's init) throws a `TypeError` that aborts app mount. A _synchronous_ such throw fires `pageerror`; its _async_ twin (the same `TypeError` via `await`/`.then()`, or a failed dynamic import) is logged on the console channel as `Uncaught (in promise) …` / `Failed to fetch dynamically imported module` — the smoke hard-fails on both. The literal `Module "…" has been externalized` text is, **in a prod build**, a build-time warning (`vite build` / `npm run build`), not a runtime message, so the browser never sees it (under `npm run dev` Vite's stub is instead a `Proxy` that `console.warn`s that string at runtime); and an externalized import that is never _called_ ships a harmless `{}` and is invisible here by design. Every _other_ console error is printed as a diagnostic, not a failure (so a benign font-CDN or React-warning `console.error` doesn't flake CI). Playwright is resolved via `createRequire` based at `clients/web/package.json` — a bare `import("playwright")` would resolve relative to `scripts/`, not the cwd, so it can't be reached that way (it only appears to work when an ancestor `node_modules` carries playwright, and fails in CI, which has none). The npm script's `cd clients/web` exists only so `npx playwright install chromium` finds the local playwright bin (a no-op when already installed). +- `smoke:web:app` (`scripts/smoke-web-app.mjs`, #1859) goes one step further again: `smoke:web:browser` stops at first paint and never connects to a server, so the Apps tab, the sandbox controller, and the UI-protocol bridge were unexercised by any smoke. This one boots the same prod `--web` server, spawns the `mcp-app-http.json` composable test server (the `mcp_app_demo` tool + its `mcp_app_demo_widget` UI resource), and drives the whole **connect → open app → widget ready** chain through a single deep-link navigate (`?serverUrl=…&autoConnect=<token>&openApp=…&appArgs=…&autoOpen=<token>`). The assertion is the `data-app-status="ready"` contract from [clients/web/README.md](clients/web/README.md) — the renderer reports `ready` only once the widget has loaded inside the sandbox iframe _and_ fired `notifications/initialized` back through the bridge, so one attribute covers the sandbox proxy being served, the UI resource loading, and the handshake completing. Two mechanics are load-bearing and easy to get wrong: the test server announces readiness on **stderr** (`console.error` in `server-composable.ts`), so both child streams are piped and scanned — watching stdout alone times out with an empty diagnostic; and its bound port is **not** the config's, because `createTestServerHttp` resolves through `findAvailablePort()`, which walks upward when the configured port is taken — so the smoke parses the announced URL rather than assuming `3130`. **Scope note:** this runs against the repo build tree like every other smoke, so it would _not_ have caught #1859 itself (a packaging failure — the file is always present in-repo); `pack:verify` owns that dimension. It does carry a cheap structural pre-check that the proxy page exists at the path `sandbox-controller.ts` resolves, so a move/rename fails fast with a clear cause instead of an opaque render timeout. +- **The build gate for the browser-externalized-builtin class (#1769)** is the earlier, more complete companion to `smoke:web:browser`. A Vite plugin in `clients/web/vite.config.ts` (logic in `clients/web/server/browser-externalized-builtin-gate.ts`, unit-tested) turns Vite 8's _browser-externalization warning_ (`Module "node:*" has been externalized for browser compatibility`) into a hard `vite build` error, so a Node built-in in the browser graph now **fails `npm run build` / `validate`** instead of shipping a `{}` stub. This catches **both** the _called-at-init_ case (which `smoke:web:browser` also catches, but later/at runtime) **and** the _imported-but-never-called_ case (the `{}` stub that is invisible to the runtime smoke "by design" — see above). Because rolldown **swallows a throw inside `onLog`** (the one hook where a thrown error doesn't abort — verified against vite@8.0.0), the plugin _records_ the warning in `onLog` and re-throws in `buildEnd`. There is **no stable log `code`**, so the gate keys off the documented message phrasing; `npm run verify:build-gate` (`scripts/verify-build-gate.mjs`, in `npm run ci` and the GitHub workflow) runs a real build with a `node:fs` probe forced into `src/main.tsx` and asserts the build fails via the gate — the only check that catches the message phrasing **drifting** in a future Vite bump and silently disabling the gate. The gate is scoped to `vite build` (`apply: 'build'`) — never `vite dev` or the vitest projects — **and** to the browser (`client`) environment (`applyToEnvironment`), so a future SSR/node environment built from this config isn't failed for a legitimate `node:*` import; the Node runner build (tsup, `build:runner`) is a separate config where built-ins are legitimate. `smoke:web:browser` stays as the runtime backstop for crashes the build can't reason about. - `smoke:cli` (`scripts/smoke-cli.mjs`) drives `mcp-inspector --cli` through the built launcher against the bundled stdio test server via a temp `--catalog`: it asserts `tools/list` returns the server's tools (real connect over stdio), the default writable catalog is seeded empty on first run, a missing read-only `--config` errors without seeding, and `--catalog` + `--config` is rejected. `smoke:tui` (`scripts/smoke-tui.mjs`) launches `mcp-inspector --tui --catalog <temp>` and asserts the Ink app renders its first frame (the "MCP Servers" panel) within a timeout, then SIGTERMs it — a shallow boot/render check, not full interaction. **`smoke:tui` is local-only: it self-skips when `process.env.CI` is set**, because the Ink TUI needs a real TTY (raw mode) that headless CI lacks — so run it (via `npm run smoke`) on your own machine before pushing. Both build `test-servers/build` on demand if it's missing. - Storybook play-function tests (`clients/web` `test:storybook`) run in headless Chromium via `@vitest/browser-playwright` (~10s). They are part of `npm run ci` (which installs Playwright chromium first); kept out of `validate` because they need the browser binary and are slower than the unit suite. ### Typescript instructions + - Use TypeScript for all new code - Follow TypeScript best practices and coding standards - NEVER use 'any' as a type @@ -481,7 +698,7 @@ The web client keeps two grab-bag directories under `clients/web/src`, split by - **`src/utils/`** — pure, side-effect-free functions. Input → output, no DOM/browser/storage I/O, no subsystem ownership. Trivially unit-testable with no mocks. (Anchors: `jsonUtils`, `schemaUtils`, `toolUtils`, `maskSecrets`, `inspectorTabs`, `deepLink`, `mcpNetworkHeaders`.) Carve-outs that are still `utils`: - _Domain types._ Pure **shared domain types plus their pure constructors/transforms** live here (`customHeaders` — `CustomHeader` + `headersToRecord`/`migrateFromLegacyAuth`, a shape staged for `ServerSettingsForm`, see `specification/v2_ux_interfaces_plan.md`, so it currently has no importer but its own test). There is no `types/` sub-bucket **inside** `lib`/`utils` — removing `lib/types/` is what the `customHeaders` move settles. - _Diagnostic logging._ `console.warn`/`console.error` does **not** count as a side effect for this rule — a validator that warns on bad input is still "pure" here (`sandbox-csp`, `jsonUtils`, `schemaUtils` all warn). - - _Importing from `@inspector/core`._ Two forms are fine: a **type-only** import is not a subsystem dependency (`pendingReauth` is pure type declarations), and **re-exporting pure functions or constants** from core is not subsystem ownership either (`oauthUx`/`oauthFlow` re-export core copy/predicates). What makes a module `lib` is wrapping core's *stateful runtime*, not merely importing from it. + - _Importing from `@inspector/core`._ Two forms are fine: a **type-only** import is not a subsystem dependency (`pendingReauth` is pure type declarations), and **re-exporting pure functions or constants** from core is not subsystem ownership either (`oauthUx`/`oauthFlow` re-export core copy/predicates). What makes a module `lib` is wrapping core's _stateful runtime_, not merely importing from it. - **`src/lib/`** — infrastructure / integration / stateful adapters. Modules that instantiate or compose subsystems, wrap the `@inspector/core` **runtime** (not just its types), touch the DOM / `window` / `sessionStorage`, or otherwise produce side effects. (Anchors: `environmentFactory` composes `InspectorClientEnvironment`; `remoteOAuthStorage` is an adapter class over `core/auth`; `oauthResume` reads/writes `sessionStorage`; `browserTabVisibility` registers `visibilitychange` listeners; `clearServerOAuthState` drives the live `InspectorClient` / `OAuthStorage`; `downloadFile` triggers browser downloads.) The top-level **`src/types/`** is a sibling of both and is not the place for new domain types — it's now purely the home for ambient `.d.ts` module stubs (e.g. the `react-syntax-highlighter` shims wired through `tsconfig.app.json` `paths`). The last plain-`.ts` domain type there, the dead `navigation.ts` `InspectorTab`, was removed in #1785, so a pure domain type belongs in `utils/`, not `src/types/`. @@ -491,6 +708,7 @@ Cross-directory imports point **one way, `lib → utils`** (infra depends on pur Nothing **enforces** the boundary: no path alias keys off it, and the coverage `include` in `clients/web/vite.config.ts` lists **both** `src/lib/**` and `src/utils/**`, so a move between them is coverage-neutral (this is why the refactor was gate-safe). It's a human-legible signal at import time, valuable in a codebase this test-heavy (the ≥90% per-file gate). Note that `include` is a **whitelist** — it names `components`/`hooks`/`theme`/`lib`/`utils`/`server` (plus the `core/*` runtime; `hooks` and `theme` were added in #1787), so a module placed **outside** those directories (`types/`, `App.tsx`, or a brand-new grab-bag) falls out of the ≥90 gate entirely, silently. The **deliberate, documented** top-level-file exceptions are `src/App.tsx` — a ~4.5k-line composition root at ~42% branch coverage (gating it is a dedicated testing/decomposition effort, not a whitelist tweak) — and the `src/main.tsx` / `src/index.ts` bootstraps (browser `createRoot` render and the bin `runWeb` re-export, the analog of `clients/cli`'s excluded `src/index.ts`). All three are called out in a comment on the `include` array itself rather than left silent. When adding a module, place it by the rule and keep it inside a gated directory; when it genuinely mixes both (e.g. `downloadFile` bundles DOM-side-effect helpers with a couple of pure ones), keep it whole on its dominant side (`lib`) rather than splitting hairs. ## React instructions + - UI Components - We are using the Mantine component library for UI. - Instructions are at https://mantine.dev/llms.txt @@ -506,7 +724,7 @@ Nothing **enforces** the boundary: no path alias keys off it, and the coverage ` - NEVER use inline code; instead extract to functions in the same file, exported or located in a shared location if immediately reusable. - In a component's file, for sub-components: - ALWAYS use Mantine components for layout and content, configured with props for styling and behavior. - - ALWAYS declare a meaningfully named subcomponent as a constant using `.withProps()` if an inline Mantine element carries two or more **static** props. A *static* prop is one whose value is a literal that configures the element's **styling, layout, or behavior** (`size="sm"`, `c="dimmed"`, `fw={500}`, `gap="xs"`, `justify="space-between"`, `variant="light"`, `withBorder`, `readOnly`, `striped`, …); dynamic props (`value`, `onChange`/`on*`, `children`, `key`, `ref`, and anything whose value is a variable/expression) do **not** count toward the two and are passed at the call site, not baked into the constant. Purely per-instance **content/accessibility** literals — `label`, `description`, `placeholder`, `title`, `aria-label`, `role` — likewise do **not** count toward the two (a `<Checkbox label="…" description="…">` with no styling/layout/behavior props stays inline); they may be baked into a constant when it already qualifies and doing so aids reuse, but they never by themselves trigger extraction. This rule applies in **all** cases: "repeated pattern" is NOT the bar — a single-use element with two or more static styling/layout/behavior props must still be extracted. Bake the static props into the `.withProps()` constant and pass the dynamic ones where it's rendered. + - ALWAYS declare a meaningfully named subcomponent as a constant using `.withProps()` if an inline Mantine element carries two or more **static** props. A _static_ prop is one whose value is a literal that configures the element's **styling, layout, or behavior** (`size="sm"`, `c="dimmed"`, `fw={500}`, `gap="xs"`, `justify="space-between"`, `variant="light"`, `withBorder`, `readOnly`, `striped`, …); dynamic props (`value`, `onChange`/`on*`, `children`, `key`, `ref`, and anything whose value is a variable/expression) do **not** count toward the two and are passed at the call site, not baked into the constant. Purely per-instance **content/accessibility** literals — `label`, `description`, `placeholder`, `title`, `aria-label`, `role` — likewise do **not** count toward the two (a `<Checkbox label="…" description="…">` with no styling/layout/behavior props stays inline); they may be baked into a constant when it already qualifies and doing so aids reuse, but they never by themselves trigger extraction. This rule applies in **all** cases: "repeated pattern" is NOT the bar — a single-use element with two or more static styling/layout/behavior props must still be extracted. Bake the static props into the `.withProps()` constant and pass the dynamic ones where it's rendered. - The following **cannot** be expressed via `.withProps()` and so stay inline (like `Box` below), each with a one-line comment saying why: **`Accordion`** (a compound, `multiple`-discriminated generic — `.withProps({ multiple: true, … })` loses its JSX call signature and fails to type); **headless, non-`factory()` Mantine components** such as **`Transition`** (plain function components with no Styles API — they have no `.withProps` static at all, e.g. `Transition.withProps` is a TS2339); and **`data-*` attributes** (not part of a component's typed props object, so excess-property-checked out of a `withProps` literal — pass them at the call site). The rule targets factory-based (Styles-API) Mantine components; anything that isn't one is out of scope entirely — a third-party element (a `react-icons` glyph, another library's component) **and** a first-party component that isn't a Mantine factory (a dumb `export function` like `ContentViewer`, which has no `.withProps` static of its own). - NEVER use `Box` for subcomponent constants — `Box` does not support `.withProps()`. Use `Group`, `Stack`, `Flex`, `Text`, `Paper`, `UnstyledButton`, or `Image` instead. Pick the component that best matches the purpose: `Paper` for bordered/surfaced containers, `Text` for any text or content wrapper, `Stack`/`Group`/`Flex` for layout. A `Box` that genuinely needs a non-flex primitive it can't provide — `component="iframe"`, or `display="grid"` (no Mantine flex primitive is a CSS grid) — stays a `Box` inline, with a one-line comment saying why. - NEVER use a CSS class on a subcomponent constant when the styles can be expressed as a Mantine theme variant instead. Define variants in `src/theme/<Component>.ts` using `Component.extend({ styles: (_theme, props) => { ... } })` and reference them with `variant="variantName"` on the component or in `.withProps()`. @@ -514,32 +732,32 @@ Nothing **enforces** the boundary: no path alias keys off it, and the coverage ` - When a theme variant needs a CSS class for nested/pseudo selectors, use `classNames` in the theme extension to auto-assign it — never add `className` manually in JSX for theme-styled components. - Example — subcomponent constant with `withProps`: ```tsx - const CardContent = Group.withProps({ - flex: 1, - align: 'flex-start', - justify: 'space-between', - wrap: 'nowrap', - }); - return <CardContent> ... </CardContent> + const CardContent = Group.withProps({ + flex: 1, + align: "flex-start", + justify: "space-between", + wrap: "nowrap", + }); + return <CardContent> ... </CardContent>; ``` - Example — theme variant with auto-assigned className for nested selectors: ```tsx - // src/theme/Paper.ts - export const ThemePaper = Paper.extend({ - classNames: (_theme, props) => { - if (props.variant === 'message') return { root: 'message' }; - return {}; - }, - styles: (_theme, props) => { - if (props.variant === 'message') { - return { root: { padding: '1.5rem', borderRadius: 12 } }; - } - return { root: {} }; - }, - }), - - // Component.tsx - const MessageContainer = Paper.withProps({ variant: 'message' }); + // src/theme/Paper.ts + export const ThemePaper = Paper.extend({ + classNames: (_theme, props) => { + if (props.variant === "message") return { root: "message" }; + return {}; + }, + styles: (_theme, props) => { + if (props.variant === "message") { + return { root: { padding: "1.5rem", borderRadius: 12 } }; + } + return { root: {} }; + }, + }); + + // Component.tsx + const MessageContainer = Paper.withProps({ variant: "message" }); ``` - State and effects - **NEVER reset or re-sync local state from a prop inside a `useEffect`.** `useEffect(() => setX(prop), [prop])` renders once with the stale value, paints it, and only then corrects itself — the user sees the wrong frame and React renders twice. It is an error under `react-hooks/set-state-in-effect`, which the web client's `eslint-plugin-react-hooks` recommended set enforces. @@ -562,4 +780,3 @@ The dev/prod web backend protects every `/api/*` route with `x-mcp-remote-auth: 3. `sessionStorage` — backstop for navigations that land without either of the above. Injection is a no-op when auth is disabled (`DANGEROUSLY_OMIT_AUTH`), and the global name is the shared `INSPECTOR_API_TOKEN_GLOBAL` constant in `core/mcp/remote/constants.ts`. - diff --git a/Dockerfile b/Dockerfile index 986231a09..f87a32ce2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,15 @@ EXPOSE 6274 # (core/storage/store-io.ts), so set it explicitly to the node user's writable # home and work from there. ENV HOME=/home/node + +# Create the runtime-state dir up front, owned by `node`. Docker seeds a named +# volume's ownership from the image's directory at the mount point — and when +# that directory does not exist it creates it `root:root`, which the non-root +# `node` user then cannot write. Without this, `-v inspector-data:/home/node/.mcp-inspector` +# (the recipe for keeping your saved servers across `docker run --rm`) makes +# every "add server" fail with `EACCES ... open '/home/node/.mcp-inspector/mcp.json.tmp-*'`. +RUN mkdir -p /home/node/.mcp-inspector && chown -R node:node /home/node/.mcp-inspector + USER node WORKDIR /home/node diff --git a/README.md b/README.md index a597b665e..55459ad07 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ npx @modelcontextprotocol/inspector --cli # CLI npx @modelcontextprotocol/inspector --tui # TUI ``` +> **Upgrading from v1?** Read the [v1 → v2 migration guide](./docs/v1-to-v2-migration.md) — CLI flags, the new `--config` vs. `--catalog` split, the Node engine bump, and what no longer ships. + > **Repo status.** This is the **v2** line of the Inspector. Active development happens on **`v2/main`** (the develop branch — all v2 PRs target it), which is merged into **`main`** at milestone releases; `main` is the default branch and holds the latest released v2, published to the npm `latest` tag. The legacy **v1** line lives on **`v1/main`** — security fixes only, published straight from that branch to the npm `v1-latest` tag (`npx @modelcontextprotocol/inspector@v1-latest`). See [`AGENTS.md`](./AGENTS.md) for branch/board conventions. ## Project layout @@ -37,8 +39,8 @@ 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) -├── docs/ # Task-oriented guides (server configuration, MCP App review, launcher/config plan) +├── 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) └── README.md # You are here @@ -49,6 +51,7 @@ Each client has its own README with client-specific detail: Task-oriented guides live under [`docs/`](./docs): +- [Migrating from v1 to v2](./docs/v1-to-v2-migration.md) — the v1 → v2 map: CLI flag mapping, `--config` vs. `--catalog` semantics with before/after examples, the Node engine bump (`>=22.7.5` → `>=22.19.0`), env-var renames, and the sub-packages that no longer ship. - [MCP server configuration](./docs/mcp-server-configuration.md) — which server(s) the Inspector connects to: `--catalog` vs. `--config`, ad-hoc targets, the `--` separator, the file format and its Inspector-specific per-server fields. Shared by all three clients; the cli and tui READMEs delegate their server-options sections to it. - [Reviewing an MCP App](./docs/mcp-app-review.md) — the CLI-first → one-shot-web recipe for automated App-tool review: `--app-info` probe → deep-link navigate → rendered widget, plus OAuth handoff and proxy support. - [Launcher and config consolidation](./docs/launcher-config-consolidation-plan.md) — why the launcher runs a client in-process rather than spawning it, and how the shared config processor fits in. @@ -66,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): @@ -132,16 +137,27 @@ Each config below is a ready-made server for exercising one feature by hand. Loa | Config | Demonstrates | Issue | | ----------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- | +| `mcp-app-http.json` **(legacy era)** | An MCP App (UI resource + app tool) in the Apps tab | [#1859](https://github.com/modelcontextprotocol/inspector/issues/1859) | | `modern-mrtr-http.json` | A single MRTR round-trip | — | | `mrtr-showcase-http.json` | Every MRTR preset in one server | — | | `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) | | `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) | | `tasks-{legacy,modern}-http.json` | Tasks, both eras | [#1631](https://github.com/modelcontextprotocol/inspector/issues/1631) | +#### MCP Apps + +`mcp-app-http.json` serves the `mcp_app_demo` tool (`_meta.ui.resourceUri`) alongside its `mcp_app_demo_widget` UI resource, so the **Apps** tab has a real App to render. It is a plain streamable-HTTP server — connect with the **default (legacy)** protocol era, not Modern. + +Open the Apps tab, select `mcp_app_demo`, give it a title and click **Open App**: the widget renders inside the sandbox iframe and exercises the host-side UI protocol surface — host-context render, `size-changed`, `ui/message`, and a log line into the **App logs** panel. Because the widget is served through the sandbox proxy page, this config is also what reproduces [#1859](https://github.com/modelcontextprotocol/inspector/issues/1859) (a missing `clients/web/static/sandbox_proxy.html` surfaces here as a "Sandbox not loaded" message in place of the widget) — a failure that only ever appeared in an installed package, never in the repo. + +For the scripted version of the same flow (`--app-info` probe → deep link → rendered widget), see [Reviewing an MCP App](./docs/mcp-app-review.md). + #### MRTR `modern-mrtr-http.json` serves the `mrtr_confirm` tool (preset `mrtr_confirm`, `createMrtrTool`) over the modern leg. Its handler returns `inputRequired(...)` embedding a form elicitation, so invoking it produces a real round-trip: `input_required` → the client fulfils the embedded elicitation and retries with a new id → `complete`. @@ -197,6 +213,20 @@ Under SDK v2 a `tools/call` rejecting with `-32602` renders as a distinct error Turn on **"Fetch Lists One Page at a Time"** (Server Settings — the `paginatedLists` setting, or the **Paginated** switch in a list sidebar) and the lists load page 1 only (4 items) with a **Load next page** control and an _N pages loaded_ status. Each click fetches the next 4 and appends them; Refresh resets to page 1. With the switch off (the default), the same lists auto-aggregate all three pages on connect. +#### Structured output + +`structured-output-http.json` serves `list_items` (nested `structuredContent` — objects inside arrays inside an object, the shape from [#1908](https://github.com/modelcontextprotocol/inspector/issues/1908)), `get_temp` (a flat three-key payload), and `echo` (no `outputSchema` at all). It is a plain streamable-HTTP server — connect with the **default (legacy)** protocol era. + +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`. @@ -252,17 +282,18 @@ Individual clients: `build:web`, `build:cli`, `build:tui`, `build:launcher`. The Each client self-validates from its own folder; the root scripts chain them. There is **no** aggregate root `test` script — use `validate` (fast) or `coverage` (the gate). -| 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 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 a headless-Chromium 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). | -| `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 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 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). | +| Script | What it does | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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` 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). | Per-client scripts exist too (`validate:web`, `coverage:cli`, `smoke:tui`, …), plus root `validate:core` / `format:core` for the shared `core/` package, `format:scripts` for the root `scripts/` tooling, and `format:shared` / `lint:shared` for the root "shared" surface (`test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`). Run `npm run format` before committing — the root `format` fixes `core/`, the root `scripts/`, the shared surface, and every client; `validate` runs the non-fixing `format:check` and fails CI on any unformatted file. @@ -278,6 +309,8 @@ The root `package.json` `"files"` allowlist is the source of truth for the tarba - **No source maps.** The client bundlers set `sourcemap: false` (`clients/{cli,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`); Vite and the launcher's `tsc` already emit none. Maps are ~half the unpacked size and aren't needed at runtime — debug via `npm run dev` on the source. - **`clients/web/build` ships via `clients/web/.npmignore`.** `clients/web/.gitignore` lists `build/`, and npm's packlist honors that nested `.gitignore` over the root `"files"` allowlist — so the prod web-server runner was silently missing from the tarball while `clients/web/dist` slipped through (its `.gitignore` only lists `dist-ssr`). `clients/web/.npmignore` overrides the `.gitignore` for publishing so both `build/` (runner) and `dist/` (SPA) ship. The other clients don't need this — none ship a nested `.gitignore`. +- **`clients/web/static` ships the MCP Apps sandbox proxy.** `clients/web/static/sandbox_proxy.html` is a committed source file (not a build artifact), read from disk at runtime by `clients/web/server/sandbox-controller.ts` as `<runner dir>/../static/sandbox_proxy.html`. It was missing from the root `"files"` allowlist entirely, so every published build failed the Apps tab with **"Sandbox not loaded"** ([#1859](https://github.com/modelcontextprotocol/inspector/issues/1859)) while working fine in the repo. Because the path is resolved _relative to_ `clients/web/build`, the directory must ship at that exact location — `pack:verify` asserts both the tarball entry and the installed-on-disk path. +- **A dependency that renders React is bundled, not externalized.** An externalized package resolves its own `react` from wherever npm placed **it** in the consumer's tree, which is not necessarily where the bundle resolves ours — npm places a package beside a React satisfying *its* peer range, and those ranges are looser than ours. `ink-form` and `ink-scroll-view` declare `">=18"`, so a project holding React 18 satisfies them and gets them hoisted while the Inspector's React 19 nests underneath: two React copies, and the TUI dies with `TypeError: Cannot read properties of null (reading 'useState')` the moment a tool test form or a scroll view mounts ([#1952](https://github.com/modelcontextprotocol/inspector/issues/1952)). Both are therefore inlined by `clients/tui/tsup.config.ts` and are **not** root dependencies: the tarball ships their code inside `clients/tui/build/index.js` rather than having consumers install them. Bundling also pins their transitive deps to what this repo's install resolved (notably `ink-select-input@6` via `overrides`, which npm ignores for a package installed as a dependency). **`ink` is the one exception, on cost:** bundling it works but adds ~1.4 MB (`react-reconciler` and `yoga-layout` come along, plus a `createRequire` banner for the inlined CJS), so it stays external — *not* because its `">=19"` peer makes it safe, which it does not. What keeps that tolerable is the root `react` range: `"^19.0.0"` is deliberately open to the whole major so npm can dedupe our React with whatever React 19 a consumer pins, leaving an external `ink` on the same copy the bundle uses. **Narrowing that range reopens the bug for the renderer itself** — `clients/tui/__tests__/tsupConfig.test.ts` pins it to `ink`'s peer floor, and guards the rest of the split; see the [TUI README](./clients/tui/README.md#bundling-react-rendering-dependencies-must-be-inlined-1952). - **A single version number, read from the root `package.json`.** The Inspector ships as one package with one version, so only the **root** `package.json` carries a `version` — the four `clients/*/package.json`s deliberately have none. Every Node client (CLI, TUI, and the web backend) resolves the version through the shared `readInspectorVersion()` reader in `core/node/version.ts`, which walks up to the root manifest (always present in the tarball). No client `package.json` is read at runtime, so none needs to ship. The web **browser** can't read the filesystem; it gets its version from the backend via `GET /api/config` (see [#1639](https://github.com/modelcontextprotocol/inspector/issues/1639)). ### `npm run pack:verify` — publish smoke against the real tarball @@ -309,14 +342,34 @@ A container image is published to GHCR (`ghcr.io/modelcontextprotocol/inspector` ```bash # run the web UI (reads the auth token from the container logs) -docker run --rm -p 6274:6274 ghcr.io/modelcontextprotocol/inspector +docker run --rm -p 127.0.0.1:6274:6274 ghcr.io/modelcontextprotocol/inspector # or build the image locally docker build -t mcp-inspector . -docker run --rm -p 6274:6274 mcp-inspector +docker run --rm -p 127.0.0.1:6274:6274 mcp-inspector +``` + +**Keep the `127.0.0.1:` prefix on the published port.** A bare `-p 6274:6274` publishes on **every host interface**, putting the Inspector on your local network. The container's `HOST=0.0.0.0` is a separate concern — it governs the _container's_ interfaces, not the host's — so the `DANGEROUSLY_BIND_ALL_INTERFACES` opt-in that guards a wildcard bind outside a container does not cover this. It matters more here than for an ordinary web app: the backend spawns processes on request, `GET /` embeds the API token into the served HTML, and a request arriving with **no** `Origin` header skips the origin allow-list entirely — so for any non-browser client the API token is the only guard. Publishing wider needs a real access-control boundary in front of the Inspector — a reverse proxy that authenticates, an SSH tunnel, a private network. Setting your own `MCP_INSPECTOR_API_TOKEN` does **not** substitute: `GET /` discloses whatever token is in use, so a custom one is harvested exactly as easily as a generated one. + +**Keeping the servers you add.** The Inspector saves your server list to `$HOME/.mcp-inspector/mcp.json`, which in the image is `/home/node/.mcp-inspector/mcp.json` — inside the container's writable layer, so `--rm` discards it and every run starts with an empty list. Mount a volume there to keep it: + +```bash +docker run --rm -p 127.0.0.1:6274:6274 \ + -v mcp-inspector-data:/home/node/.mcp-inspector \ + ghcr.io/modelcontextprotocol/inspector +``` + +The same volume also persists OAuth tokens and stored state, so an authorized server stays authorized across runs. Use `-e MCP_CATALOG_PATH=/some/other/path.json` to put the catalog somewhere else — mount a volume covering whatever directory you point it at. If you **bind-mount a host directory** instead of a named volume (`-v "$PWD/inspector-data:/home/node/.mcp-inspector"`), the directory keeps its host ownership, so on Linux add `--user "$(id -u):$(id -g)"` or `chown` it to uid `1000` — otherwise the non-root `node` user can't write and adding a server fails with `EACCES`. + +**Upgrading from an image before this fix?** Earlier images did not create `/home/node/.mcp-inspector`, so Docker created the volume's mount point as `root` and the non-root `node` user couldn't write to it. An **empty** volume repairs itself on the first run of a current image (Docker applies the image directory's ownership to an empty volume), but one that already has files in it keeps its old `root` ownership and still fails with `EACCES`. Fix it once: + +```bash +docker run --rm -u 0 --entrypoint chown \ + -v mcp-inspector-data:/data ghcr.io/modelcontextprotocol/inspector \ + -R node:node /data ``` -The image defaults to `--web` bound to `0.0.0.0:6274` with browser auto-open disabled; override the args to run another mode (`docker run --rm ghcr.io/modelcontextprotocol/inspector --cli …`). Pass `-e MCP_INSPECTOR_API_TOKEN=…` to set a known token (otherwise one is generated and printed in the logs), or `-e DANGEROUSLY_OMIT_AUTH=true` to disable auth. Binding `0.0.0.0` (all network interfaces) is refused by default outside a container — it exposes the process-spawning backend to the local network — so the image opts in explicitly with `DANGEROUSLY_BIND_ALL_INTERFACES=true` (already set in the `Dockerfile`); a bare `HOST=0.0.0.0` without that flag exits with an error. If you **remap the published port** (`-p 8080:6274`), the browser's origin (`http://localhost:8080`) no longer matches the in-container port, so set `-e ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080` (or run `-e CLIENT_PORT=8080 -p 8080:8080`) or connects will 403. `ALLOWED_ORIGINS` **replaces** the default list rather than merging, so list every loopback form you'll browse from (see the [web README](./clients/web/README.md#host-binding--the-origin-allow-list)). The image runs as the non-root `node` user and has a `HEALTHCHECK` that probes the web UI — it assumes the default `--web` mode, so add `--no-healthcheck` when running `--cli`/`--tui` (which have no web server). +The image defaults to `--web` bound to `0.0.0.0:6274` with browser auto-open disabled; override the args to run another mode (`docker run --rm ghcr.io/modelcontextprotocol/inspector --cli …`). Pass `-e MCP_INSPECTOR_API_TOKEN=…` to set a known token (otherwise one is generated and printed in the logs), or `-e DANGEROUSLY_OMIT_AUTH=true` to disable auth. Binding `0.0.0.0` (all network interfaces) is refused by default outside a container — it exposes the process-spawning backend to the local network — so the image opts in explicitly with `DANGEROUSLY_BIND_ALL_INTERFACES=true` (already set in the `Dockerfile`); a bare `HOST=0.0.0.0` without that flag exits with an error. If you **remap the published port** (`-p 127.0.0.1:8080:6274`), the browser's origin (`http://localhost:8080`) no longer matches the in-container port, so set `-e ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080` (or run `-e CLIENT_PORT=8080 -p 127.0.0.1:8080:8080`) or connects will 403. `ALLOWED_ORIGINS` **replaces** the default list rather than merging, so list every loopback form you'll browse from (see the [web README](./clients/web/README.md#host-binding--the-origin-allow-list)). The image runs as the non-root `node` user and has a `HEALTHCHECK` that probes the web UI — it assumes the default `--web` mode, so add `--no-healthcheck` when running `--cli`/`--tui` (which have no web server). ## Contributing — `AGENTS.md` and `CLAUDE.md` diff --git a/clients/cli/README.md b/clients/cli/README.md index b3da972b6..d5c033df5 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -12,6 +12,8 @@ npx @modelcontextprotocol/inspector --cli node build/index.js Supports tools, resources, and prompts (plus `--method servers/list` / `servers/show` for catalog entries without connecting). +> Coming from the v1 CLI? See the [v1 → v2 migration guide](../../docs/v1-to-v2-migration.md) — every v1 flag still exists, but exit codes, argument ordering, and the `--` separator changed. + ### Examples **Basic usage** diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index ee8ea1e44..0e30913b8 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -8,10 +8,6 @@ "name": "@modelcontextprotocol/inspector-cli", "license": "MIT", "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", @@ -19,7 +15,7 @@ "open": "^10.2.0", "pino": "^9.14.0", "undici": "^8.5.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "bin": { "mcp-inspector-cli": "build/index.js" @@ -807,76 +803,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/@modelcontextprotocol/server": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", - "integrity": "sha512-i1E5l75rQKsgY/AKAIspgMBH1vEL7dqiK7tHr0L+raYcb0SWOziqNGJXGIG6NY4AlXDWIKGJQGB7Nqfs3oUi5g==", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/server-legacy": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0-beta.5.tgz", - "integrity": "sha512-8BemN4avQnG6Fu660fZCqnPGpeyL7gg5kxUceZQh7JCt8oqzX1bwJkNV+cKS01LPAaPbl93INneD+mBtJWKWvQ==", - "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "express-rate-limit": "^8.2.1", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "express": "^4.18.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "express": { - "optional": true - } - } - }, "node_modules/@napi-rs/keyring": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", @@ -2292,20 +2218,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "peer": true, - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2403,31 +2315,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "peer": true, - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -2472,15 +2359,6 @@ "esbuild": ">=0.18" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -2491,37 +2369,6 @@ "node": ">=8" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2574,29 +2421,6 @@ "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2604,47 +2428,11 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/cross-spawn": { "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", @@ -2659,6 +2447,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2719,15 +2508,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2738,58 +2518,6 @@ "node": ">=8" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT", - "peer": true - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", @@ -2797,19 +2525,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -2852,13 +2567,6 @@ "@esbuild/win32-x64": "0.27.7" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT", - "peer": true - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3061,37 +2769,6 @@ "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "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", @@ -3102,68 +2779,6 @@ "node": ">=12.0.0" } }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "peer": true, - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", - "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", - "license": "MIT", - "dependencies": { - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3231,28 +2846,6 @@ "node": ">=16.0.0" } }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3303,26 +2896,6 @@ "dev": true, "license": "ISC" }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3338,55 +2911,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "peer": true, - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3413,19 +2937,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3436,32 +2947,6 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "peer": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3469,42 +2954,6 @@ "dev": true, "license": "MIT" }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3525,31 +2974,6 @@ "node": ">=0.8.19" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -3606,13 +3030,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT", - "peer": true - }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -3632,6 +3049,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": { @@ -3673,15 +3091,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", @@ -4100,66 +3509,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "peer": true, - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -4193,6 +3542,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -4233,38 +3583,16 @@ "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -4288,29 +3616,6 @@ "node": ">=14.0.0" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "peer": true, - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "peer": true, - "dependencies": { - "wrappy": "1" - } - }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -4379,16 +3684,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4403,22 +3698,12 @@ "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" } }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -4493,15 +3778,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", @@ -4628,20 +3904,6 @@ ], "license": "MIT" }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "peer": true, - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4652,53 +3914,12 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -4820,23 +4041,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -4858,12 +4062,6 @@ "node": ">=10" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -4877,63 +4075,11 @@ "node": ">=10" } }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "peer": true, - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, "node_modules/shebang-command": { "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" @@ -4946,87 +4092,12 @@ "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" } }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -5079,15 +4150,6 @@ "dev": true, "license": "MIT" }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -5229,15 +4291,6 @@ "node": ">=14.0.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -5342,39 +4395,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "license": "MIT", - "peer": true, - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -5436,15 +4456,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -5455,15 +4466,6 @@ "punycode": "^2.1.0" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vite": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", @@ -5652,6 +4654,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" @@ -5690,13 +4693,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC", - "peer": true - }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", diff --git a/clients/cli/package.json b/clients/cli/package.json index 598fc7f37..54f2dc5c4 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -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", diff --git a/clients/tui/README.md b/clients/tui/README.md index 7a784a975..1ae2fb514 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -102,7 +102,93 @@ The repo-root `validate:tui` just delegates here. `eslint.config.js` registers stricter react-hooks@7 rules are not enforced on the interim component surface (#1501). -Tests live in `__tests__/`. The coverage gate currently covers the TUI's -non-React logic (server resolution, logger, tab metadata, and the `utils/` -form/URL helpers); the Ink components and `App.tsx` are an interim exclusion in -`vitest.config.ts` pending a renderer-based follow-up. +Tests live in `__tests__/`. The coverage gate covers **all of `src/**`**, React +surface included — the Ink components mount through `ink-testing-library` (with +the `ink-scroll-view` / `ink-form` passthrough doubles in `__tests__/helpers/`), +`App.tsx` mounts against a mock of the `@inspector/core` surface, and keypresses +are driven through stdin. The former interim exclusion of the components and +`App.tsx` was lifted in #1501; the only exclusion left in `vitest.config.ts` is +`src/tui-servers.ts`, a pure re-export of core's server resolver with no runtime +statements of its own (its logic is measured in `core/` via the web suite, and +`tui-servers.test.ts` still exercises it behaviorally — it is excluded only so it +doesn't surface as a misleading 0/0 row). + +### Bundling: React-rendering dependencies must be inlined (#1952) + +`tsup.config.ts` splits the TUI's dependencies into bundled (`noExternal`) and +externalized (`external`). For anything that **renders React components**, that +choice decides which React instance it gets at runtime, and getting it wrong +crashes the TUI in a way nothing in this repo can see. + +An external package's `import "react"` resolves from wherever npm placed **that +package** — and npm places it beside a React satisfying *its* peer range, which +is looser than ours in every case here: + +| Package | Its `react` peer | Placed beside a different React when… | +| --- | --- | --- | +| `ink-form`, `ink-scroll-view` | `">=18"` | the consumer has React 18 | +| `ink` | `">=19"` | the consumer pins React 19.0 (our `^19.2.4` then nests) | + +Either way the bundle renders through one React while the external package calls +hooks on another, whose dispatcher is null — so opening a tool test form (or any +scroll view, or in `ink`'s case simply starting the TUI) dies with +`TypeError: Cannot read properties of null (reading 'useState')`. + +`ink-form` and `ink-scroll-view` are therefore **inlined**, which removes npm +from the decision: an inlined package's `import "react"` is emitted into +`build/index.js`, so it resolves from the build directory exactly like the +bundle's own. Inlining also pins transitive deps to what *this* install +resolved, notably `ink-select-input@6` via the `overrides` entry — npm ignores a +dependency's `overrides`, so a consumer install would otherwise pull the +React-18-era v5 that `ink-form` asks for. + +#### Why `ink` itself is the exception + +`ink` is external for **cost**, not because its `">=19"` peer makes it safe — it +does not, and an earlier revision of this section wrongly claimed it did. +Bundling `ink` works (it was built and verified against both consumer repros) +but adds ~1.4 MB, since `react-reconciler` and `yoga-layout` come with it, plus +a `createRequire` banner: inlined CJS calls `require` at runtime +(`react-reconciler` for `"react"`, `signal-exit@3` for `"assert"`) and esbuild's +interop shim throws `Dynamic require of "x" is not supported` without a real +`require` in scope. A 602 KB bundle beat a 2 MB one. + +What makes the exemption tolerable is a **separate lever**: the root manifest +declares `react: "^19.0.0"` — open to the whole major, rather than pinned at the +version we happen to develop against. That lets npm satisfy our React and a +consumer's pinned React 19.x with a single copy, so an external `ink` resolves +*ours*. Verified against a consumer pinning `react@19.0.0` alongside `ink@6.8.0` +— the case that splits under a narrower range: + +``` +bundle react node_modules/react/index.js +ink -> react node_modules/react/index.js # same copy +``` + +Narrow that range and the exemption turns straight back into #1952, one level +up — breaking TUI startup rather than just its forms. `tsupConfig.test.ts` pins +the root range to `ink`'s own peer floor so it can't drift silently. The +residual after all this is a React **20**-era consumer that also depends on +`ink`; that gets revisited when the Inspector moves to React 20. + +`__tests__/tsupConfig.test.ts` enforces the whole split: every dependency +declaring a `react` peer must be in `noExternal` unless it is listed as external +by design, each exempt package must also be a root dependency (external means +consumers install it), and the root `react` range must stay open to the major. +Add a React-rendering dependency, and that test tells you to bundle it. + +### The `ink-form` label patch + +Bundling `ink-form` also makes it patchable, which one label needs: the hint +under an incomplete form reads "you have not **competed** yet". It is upstream's +string, hardcoded in `ink-form/lib/SubmitButton.js` with no prop to override, +and `ink-form` was last published in 2024 — so `tsup.config.ts` corrects it with +an esbuild `onLoad` hook as the file enters the bundle. It is reported upstream +as [lukasbach/ink-form#14](https://github.com/lukasbach/ink-form/issues/14); if +a release ever carries the fix, drop the patch. + +The hook **throws** when the string isn't found rather than passing the source +through. A silent no-op would let an `ink-form` upgrade retire the patch without +anyone noticing it had stopped applying — or leave a patch aimed at a string +that no longer exists. If the build fails there, check whether upstream fixed +the typo and delete the patch instead of re-targeting it. diff --git a/clients/tui/__tests__/tsupConfig.test.ts b/clients/tui/__tests__/tsupConfig.test.ts new file mode 100644 index 000000000..a9bfc35f3 --- /dev/null +++ b/clients/tui/__tests__/tsupConfig.test.ts @@ -0,0 +1,262 @@ +/** + * Bundling invariant for the TUI's React-rendering dependencies (#1952). + * + * The published TUI is a single ESM bundle whose bare `import "react"` resolves + * from `clients/tui/build/`. Any package left *external* resolves its own + * `react` from wherever npm placed **it** instead — and npm places a package + * next to a version satisfying its declared peer range. `ink-form` and + * `ink-scroll-view` accept `react: ">=18"`, so a consumer project holding React + * 18 satisfies them, they hoist to that project's root, and the Inspector's + * React 19 nests beneath it. Two React copies later, the first hook either of + * them calls reads a null dispatcher and the TUI dies with + * "Cannot read properties of null (reading 'useState')" — the moment a tool + * test form or a scroll view mounts. + * + * Inlining them removes npm from the decision entirely: their `import "react"` + * is emitted into the bundle, so it resolves exactly where the bundle's does. + * + * This test is the durable guard, because the failure is invisible in the repo + * (a dev install has one React) and in every smoke (same) — it only appears + * once the package is installed *under* another project that renders React. + */ + +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Options } from "tsup"; +import tsupConfig, { + INK_FORM_INCOMPLETE_HINT, + INK_FORM_SUBMIT_BUTTON, + fixInkFormIncompleteHint, + inkFormLabelPatch, +} from "../tsup.config.js"; + +const clientDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +/** + * `defineConfig()` is typed as a union — a single options object, an array of + * them, or a function returning either. The TUI config is the first form, so + * narrow to it at runtime rather than assuming: a future config that grows a + * second build should fail loudly here instead of silently checking nothing. + */ +function singleConfig(config: typeof tsupConfig): Options { + if (typeof config !== "object" || Array.isArray(config)) { + throw new Error( + "clients/tui/tsup.config.ts is expected to export one options object", + ); + } + return config; +} + +/** The string entries of an `external` / `noExternal` list (ours are all strings but the type allows RegExp). */ +function stringEntries(list: (string | RegExp)[] | undefined): string[] { + return (list ?? []).filter((entry) => typeof entry === "string"); +} + +/** + * React-rendering dependencies left external by an explicit trade-off. + * + * `ink` is here for its **cost**, not because its peer range makes it safe. + * Bundling it works (verified against both consumer repros) but adds ~1.4MB — + * react-reconciler and yoga-layout come with it — plus a `createRequire` banner + * for the inlined CJS. The smaller tarball won. + * + * The exemption is only tolerable because the root manifest keeps `react` open + * to the whole major (`^19.0.0`), which lets npm dedupe our React with whatever + * React 19 a consumer pins — so an external `ink` resolves the same copy the + * bundle does. The test below pins that relationship: narrowing the root range + * silently reopens #1952 for the renderer itself. + */ +const EXTERNAL_BY_DESIGN = new Set(["ink"]); + +function readPackageJson(name: string): { + peerDependencies?: Record<string, string>; +} { + // Resolve through the client's own node_modules rather than `require.resolve` + // on the package name: several of these packages export only their entry + // point, so `<name>/package.json` is not a resolvable subpath. + const manifest = path.join(clientDir, "node_modules", name, "package.json"); + return JSON.parse(readFileSync(manifest, "utf8")); +} + +/** Dependencies that declare a `react` peer — i.e. that render React themselves. */ +function reactRenderingDependencies(): string[] { + const { dependencies } = JSON.parse( + readFileSync(path.join(clientDir, "package.json"), "utf8"), + ) as { dependencies: Record<string, string> }; + + return Object.keys(dependencies).filter( + (name) => readPackageJson(name).peerDependencies?.react !== undefined, + ); +} + +/** The real `ink-form` module the label patch targets. */ +const submitButtonPath = path.join( + clientDir, + "node_modules", + "ink-form", + "lib", + "SubmitButton.js", +); + +const config = singleConfig(tsupConfig); +const noExternal = stringEntries(config.noExternal); +const external = stringEntries(config.external); + +describe("tui tsup config", () => { + it("finds the React-rendering dependencies to check", () => { + // A guard on the guard: if this resolves to nothing (a rename, a moved + // node_modules), every assertion below would pass vacuously. + expect(reactRenderingDependencies().length).toBeGreaterThan(0); + }); + + it("bundles every React-rendering dependency that is not exempt", () => { + const shouldInline = reactRenderingDependencies().filter( + (name) => !EXTERNAL_BY_DESIGN.has(name), + ); + + expect(shouldInline.length).toBeGreaterThan(0); + for (const name of shouldInline) { + expect( + noExternal, + `${name} renders React, so it must be bundled`, + ).toContain(name); + expect(external, `${name} must not be external`).not.toContain(name); + } + }); + + it("keeps the root react range open to the whole major, so npm can dedupe", () => { + // This is what keeps the one exemption tolerable. `ink` is external, so it + // resolves whatever React npm placed beside it — and npm can only place it + // beside *ours* if our range admits the consumer's React too. Pinning the + // root range above the major floor (say `^19.2.4`) means a consumer holding + // React 19.0 gets a second copy: `ink` renders through theirs, the bundle + // through ours, and the TUI dies at startup on a null dispatcher. + // + // So the root range must start at the same floor `ink`'s own peer does. + const rootReact = ( + JSON.parse( + readFileSync(path.join(clientDir, "..", "..", "package.json"), "utf8"), + ) as { dependencies: Record<string, string> } + ).dependencies.react; + const inkPeerReact = readPackageJson("ink").peerDependencies?.react ?? ""; + + const floor = /^>=(\d+)\.0\.0$/.exec(inkPeerReact)?.[1]; + expect(floor, `unexpected ink peer range: ${inkPeerReact}`).toBeDefined(); + expect( + rootReact, + `root react must be ^${floor}.0.0 so npm can dedupe with any React ${floor} a consumer pins`, + ).toBe(`^${floor}.0.0`); + }); + + it("keeps each exempt package external, and declared for consumers", () => { + // An external package is not shipped in the bundle, so the root manifest + // has to install it — the mirror image of the inlined ones, which must NOT + // be root dependencies. Getting this backwards breaks the published TUI at + // startup with an unresolved import. + const rootDependencies = JSON.parse( + readFileSync(path.join(clientDir, "..", "..", "package.json"), "utf8"), + ) as { dependencies: Record<string, string> }; + + for (const name of EXTERNAL_BY_DESIGN) { + expect(external, `${name} is external by design`).toContain(name); + expect( + Object.keys(rootDependencies.dependencies), + `${name} is external, so consumers must install it`, + ).toContain(name); + } + }); + + it("keeps react itself external, as the single shared instance", () => { + expect(external).toContain("react"); + expect(noExternal).not.toContain("react"); + }); + + it("corrects ink-form's misspelled incomplete-form hint", () => { + // Read the real dependency, so an `ink-form` upgrade that fixes or rewords + // the label fails here — the patch must then be removed, not left silently + // matching nothing. + const patched = fixInkFormIncompleteHint( + readFileSync(submitButtonPath, "utf8"), + submitButtonPath, + ); + expect(patched).toContain(INK_FORM_INCOMPLETE_HINT.fixed); + expect(patched).not.toContain(INK_FORM_INCOMPLETE_HINT.typo); + }); + + it("routes the real module through the plugin, not just the helper", async () => { + // The helper is only reached if the plugin's onLoad filter matches. Drive + // the plugin as esbuild would — register, then invoke — so a filter that + // stops matching the real module's path fails here instead of no-opping + // through a green build (the exact silence this patch exists to prevent). + type OnLoadCallback = (args: { + path: string; + }) => Promise<{ contents: string }>; + const registered: { filter: RegExp; callback: OnLoadCallback }[] = []; + + const build = { + onLoad: (options: { filter: RegExp }, callback: OnLoadCallback) => + registered.push({ filter: options.filter, callback }), + }; + // esbuild's `PluginBuild` carries far more than this patch touches, and the + // stub above deliberately implements only the one hook it registers — so + // the double cast is bridging a real structural gap, not hiding a mismatch. + // A hook the patch called but the stub lacks fails as undefined here rather + // than passing silently. + inkFormLabelPatch.setup( + build as unknown as Parameters<typeof inkFormLabelPatch.setup>[0], + ); + + expect(registered).toHaveLength(1); + const [{ filter, callback }] = registered; + expect(filter.test(submitButtonPath)).toBe(true); + + const { contents } = await callback({ path: submitButtonPath }); + expect(contents).toContain(INK_FORM_INCOMPLETE_HINT.fixed); + expect(contents).not.toContain(INK_FORM_INCOMPLETE_HINT.typo); + }); + + it("scopes the patch to ink-form's SubmitButton and nothing else", () => { + expect(INK_FORM_SUBMIT_BUTTON.test(submitButtonPath)).toBe(true); + // A Windows-style path must match too — the filter runs against whatever + // esbuild resolved, and its separator is the platform's. + expect( + INK_FORM_SUBMIT_BUTTON.test( + "C:\\repo\\node_modules\\ink-form\\lib\\SubmitButton.js", + ), + ).toBe(true); + for (const other of [ + "/repo/node_modules/ink-form/lib/Form.js", + "/repo/node_modules/ink-select-input/build/SubmitButton.js", + "/repo/src/SubmitButton.jsx", + ]) { + expect(INK_FORM_SUBMIT_BUTTON.test(other), other).toBe(false); + } + }); + + it("fails loudly rather than silently skipping a label it cannot find", () => { + expect(() => + fixInkFormIncompleteHint("no such label here", "SubmitButton.js"), + ).toThrow(/no longer contains the ink-form label/); + }); + + it("only rewrites the misspelling", () => { + // The two strings must differ by exactly the fix, or the patch is silently + // changing copy nobody reviewed. + expect(INK_FORM_INCOMPLETE_HINT.fixed).toBe( + INK_FORM_INCOMPLETE_HINT.typo.replace("competed", "completed"), + ); + }); + + it("verifies the deps it exempts are still declared", () => { + // If `ink` ever leaves the dependency list, the exemption above is stale + // and would silently excuse a future package that took its name. + for (const name of EXTERNAL_BY_DESIGN) { + expect(reactRenderingDependencies()).toContain(name); + } + }); +}); diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index 0b3dbd3ea..42812dbb7 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -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" diff --git a/clients/tui/package.json b/clients/tui/package.json index 9a3278bc5..17839b57d 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -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" diff --git a/clients/tui/tsup.config.ts b/clients/tui/tsup.config.ts index 92a637b37..2c0197a9a 100644 --- a/clients/tui/tsup.config.ts +++ b/clients/tui/tsup.config.ts @@ -1,10 +1,76 @@ -import { defineConfig } from "tsup"; +import { defineConfig, type Options } from "tsup"; +import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(dirname, "../.."); +/** + * `ink-form` hardcodes a misspelled hint under an incomplete form — "you have + * not competed yet" — with no prop to override it. It is upstream's string + * (`ink-form/lib/SubmitButton.js`), last published in 2024, but it renders in + * the Inspector's tool/prompt/resource test forms, so we correct it on the way + * into the bundle. Reported upstream as lukasbach/ink-form#14; drop this patch + * if a release ever carries the fix. + * + * This is only possible because `ink-form` is inlined (see `noExternal` below). + */ +export const INK_FORM_INCOMPLETE_HINT = { + typo: "There are still required inputs you have not competed yet.", + fixed: "There are still required inputs you have not completed yet.", +}; + +/** + * Applies the correction above, throwing if the string is no longer there. + * + * Failing loudly is the point: a silent no-op would let an `ink-form` upgrade + * (or a fixed upstream, or a reworded label) quietly retire this patch with + * nobody noticing it had stopped applying — or, worse, leave a patch here for a + * string that no longer exists. If this throws, check whether upstream fixed + * the typo; if so, delete this patch rather than re-targeting it. + */ +export function fixInkFormIncompleteHint(source: string, file: string): string { + if (!source.includes(INK_FORM_INCOMPLETE_HINT.typo)) { + throw new Error( + `${file} no longer contains the ink-form label this build patches ` + + `(${JSON.stringify(INK_FORM_INCOMPLETE_HINT.typo)}). If upstream fixed ` + + `the typo, remove fixInkFormIncompleteHint from tsup.config.ts.`, + ); + } + return source.replaceAll( + INK_FORM_INCOMPLETE_HINT.typo, + INK_FORM_INCOMPLETE_HINT.fixed, + ); +} + +/** + * Which module the patch above is applied to. + * + * Exported so the tests can check it against the *resolved* path of the real + * `ink-form` module — a filter that stops matching is the one way this patch + * can silently no-op, since `fixInkFormIncompleteHint` would then never run. + */ +export const INK_FORM_SUBMIT_BUTTON = /ink-form[\\/]lib[\\/]SubmitButton\.js$/; + +// The plugin type is derived from tsup rather than imported from `esbuild`: +// `esbuild` is tsup's transitive dependency, not a declared one of this client, +// so a direct import typechecks only while npm happens to hoist it. +type EsbuildPlugin = NonNullable<Options["esbuildPlugins"]>[number]; + +export const inkFormLabelPatch: EsbuildPlugin = { + name: "ink-form-label-patch", + setup(build) { + build.onLoad( + { filter: INK_FORM_SUBMIT_BUTTON }, + async ({ path: file }) => ({ + contents: fixInkFormIncompleteHint(await readFile(file, "utf8"), file), + loader: "js", + }), + ); + }, +}; + export default defineConfig({ entry: ["index.ts"], format: ["esm"], @@ -15,12 +81,39 @@ export default defineConfig({ sourcemap: false, target: "node22", platform: "node", - noExternal: [/^@inspector\/core/], + // Every package here renders React components, so it MUST share the one React + // instance the bundle imports. Bundling is what guarantees that: an inlined + // package's `import "react"` is emitted into build/index.js, so it resolves + // from *this* directory exactly like the bundle's own, and no consumer install + // layout can point it elsewhere (#1952). + // + // Left external, npm is free to place a package beside a *different* React, + // because it places one beside a version satisfying that package's own peer + // range — looser than ours in every case here. `ink-form` and + // `ink-scroll-view` accept ">=18", so a consumer's React 18 satisfies them + // while the Inspector's React 19 nests underneath: two React copies, and the + // first hook they call reads a null dispatcher ("Cannot read properties of + // null (reading 'useState')") the moment a tool test form or a scroll view + // mounts. That is the reported crash, and inlining them is its fix. + // + // `__tests__/tsupConfig.test.ts` guards this list. + noExternal: [/^@inspector\/core/, "ink-form", "ink-scroll-view"], external: [ + // `react` is deliberately external — the single instance every inlined + // package above resolves to, from this build directory. "react", + // `ink` is external by a deliberate trade-off, NOT because a ">=19" peer + // makes it safe — it does not, and that claim was wrong here once already. + // Bundling it works (verified) but costs ~1.4MB, since react-reconciler and + // yoga-layout come with it, plus a `createRequire` banner for the inlined + // CJS. The smaller tarball won. + // + // What makes that tolerable is the root manifest's `react: ^19.0.0`: being + // open to the whole major lets npm satisfy our React and a consumer's + // pinned one with a single copy, so an external `ink` resolves *ours*. Narrow + // that range and this exemption turns back into the #1952 crash, one level + // up — `__tests__/tsupConfig.test.ts` guards it. "ink", - "ink-form", - "ink-scroll-view", "open", "commander", "pino", @@ -28,6 +121,7 @@ export default defineConfig({ "@modelcontextprotocol/core", "@napi-rs/keyring", ], + esbuildPlugins: [inkFormLabelPatch], esbuildOptions(options) { options.alias = { "@inspector/core": path.join(repoRoot, "core"), diff --git a/clients/web/.storybook/vitest.setup.ts b/clients/web/.storybook/vitest.setup.ts deleted file mode 100644 index fd7ac45e5..000000000 --- a/clients/web/.storybook/vitest.setup.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview"; -import { setProjectAnnotations } from "@storybook/react-vite"; -import * as projectAnnotations from "./preview"; - -// This is an important step to apply the right configuration when testing your stories. -// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations -setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]); diff --git a/clients/web/README.md b/clients/web/README.md index c835aaf27..a3facd925 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -172,7 +172,7 @@ The guard blocks only the **wildcard** all-interfaces addresses. Binding a **spe - **Bind a specific address.** `HOST=192.168.1.50` (a LAN IP) or a public IP works directly: the default origin allow-list follows the bind host, so `allowedOrigins` becomes `http://<that-host>:PORT` and a browser hitting that address is accepted with no extra config. (The host is canonicalized the way a browser is — so `HOST=127.1` advertises `http://127.0.0.1:PORT`, an IPv6 bind host is bracketed as `http://[2001:db8::1]:PORT`, and the port is dropped when it's the http default `:80` — matching what the browser sends.) - **Behind TLS or a reverse proxy**, the browser's `Origin` becomes the public origin (e.g. `https://inspector.example.com`, often without a port), which won't match the auto-derived `http://<bind-host>:PORT`. Set `ALLOWED_ORIGINS` to the real public origin(s): `ALLOWED_ORIGINS=https://inspector.example.com`. -- **Using the `0.0.0.0` wildcard** (opt-in via `DANGEROUSLY_BIND_ALL_INTERFACES=true`, as the Docker image does): a wildcard bind also serves loopback, so the default allow-list is the loopback trio plus the canonical wildcard origins (`http://0.0.0.0:PORT`, `http://[::]:PORT`), and **local access works out of the box** — `docker run -p 6274:6274` browsed at `http://localhost:6274` connects with no extra config. Reaching it at a **non-loopback** address (a LAN IP, a public hostname) still needs `ALLOWED_ORIGINS` — but since that **replaces** the default, keep the loopback forms in the list if you also browse locally: `ALLOWED_ORIGINS=http://localhost:PORT,http://127.0.0.1:PORT,http://192.168.1.50:PORT,https://inspector.example.com`. +- **Using the `0.0.0.0` wildcard** (opt-in via `DANGEROUSLY_BIND_ALL_INTERFACES=true`, as the Docker image does): a wildcard bind also serves loopback, so the default allow-list is the loopback trio plus the canonical wildcard origins (`http://0.0.0.0:PORT`, `http://[::]:PORT`), and **local access works out of the box** — `docker run -p 127.0.0.1:6274:6274` browsed at `http://localhost:6274` connects with no extra config. Reaching it at a **non-loopback** address (a LAN IP, a public hostname) still needs `ALLOWED_ORIGINS` — but since that **replaces** the default, keep the loopback forms in the list if you also browse locally: `ALLOWED_ORIGINS=http://localhost:PORT,http://127.0.0.1:PORT,http://192.168.1.50:PORT,https://inspector.example.com`. The bind-host guard and the `ALLOWED_ORIGINS` allow-list apply to both the prod server and `--dev`. Note that in **`--dev`** the Vite dev server _additionally_ enforces its own `server.allowedHosts` Host-header check, whose default accepts loopback and IP-literal hosts. The host you **bind** is auto-allowed (Vite adds the resolved `server.host` — which this config sets from `HOST` — to the allow-list), so `HOST=<hostname>` works out of the box under `--dev` too. What needs an explicit `server.allowedHosts` entry is reaching the dev server at a **different** name than the one bound — e.g. a wildcard bind reached by hostname, or a reverse-proxy domain. For those, prefer the prod server (`mcp-inspector --web`) or add the host to `server.allowedHosts`. diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 22c54dd7e..6bbfc0e07 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -15,11 +15,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", @@ -35,7 +30,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" }, "bin": { "mcp-inspector-web": "build/index.js" @@ -64,7 +59,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", @@ -1440,146 +1434,6 @@ "react": ">=16" } }, - "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/@modelcontextprotocol/ext-apps": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.5.tgz", - "integrity": "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==", - "license": "MIT", - "workspaces": [ - "examples/*" - ], - "dependencies": { - "@standard-schema/spec": "^1.1.0" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@hono/node-server": "^1.19.9 || ^2.0.5", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", - "integrity": "sha512-i1E5l75rQKsgY/AKAIspgMBH1vEL7dqiK7tHr0L+raYcb0SWOziqNGJXGIG6NY4AlXDWIKGJQGB7Nqfs3oUi5g==", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/server-legacy": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0-beta.5.tgz", - "integrity": "sha512-8BemN4avQnG6Fu660fZCqnPGpeyL7gg5kxUceZQh7JCt8oqzX1bwJkNV+cKS01LPAaPbl93INneD+mBtJWKWvQ==", - "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "express-rate-limit": "^8.2.1", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "express": "^4.18.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "express": { - "optional": true - } - } - }, "node_modules/@napi-rs/keyring": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", @@ -3310,6 +3164,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, "license": "MIT" }, "node_modules/@storybook/addon-a11y": { @@ -4528,19 +4383,6 @@ "dev": true, "license": "MIT" }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -4580,24 +4422,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -4754,43 +4578,6 @@ "node": ">=6.0.0" } }, - "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -4882,15 +4669,6 @@ "esbuild": ">=0.18" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -4901,35 +4679,6 @@ "node": ">=8" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -5128,69 +4877,12 @@ "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "license": "MIT" }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -5220,6 +4912,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", @@ -5330,15 +5023,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -5407,26 +5091,6 @@ "csstype": "^3.0.2" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, "node_modules/electron-to-chromium": { "version": "1.5.399", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", @@ -5444,15 +5108,6 @@ "node": ">=14" } }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -5475,15 +5130,6 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -5500,18 +5146,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -5564,12 +5198,6 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -5837,36 +5465,6 @@ "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "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", @@ -5877,68 +5475,6 @@ "node": ">=12.0.0" } }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.6.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", - "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -6025,27 +5561,6 @@ "node": ">=16.0.0" } }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -6110,24 +5625,6 @@ "node": ">=0.4.x" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -6162,30 +5659,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -6195,19 +5668,6 @@ "node": ">=6" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -6252,18 +5712,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -6301,18 +5749,6 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -6462,42 +5898,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6544,35 +5944,11 @@ "node": ">=8" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", - "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", - "license": "MIT" - }, - "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" }, "node_modules/is-alphabetical": { "version": "2.0.1", @@ -6707,12 +6083,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -6732,6 +6102,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": { @@ -6773,15 +6144,6 @@ "node": ">=8" } }, - "node_modules/jose": { - "version": "6.2.7", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.7.tgz", - "integrity": "sha512-hq1OB1bALKfydZNoViyg6hPVGV4i93ny9Op+n4zP5RSf7SCZEXa/TsG2O3IEr7+WlHRTPnpqDmHfMH6qXAD60w==", - "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", @@ -6829,13 +6191,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause", - "peer": true - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -7338,15 +6693,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -7629,31 +6975,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/media-typer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", - "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -8217,31 +7538,6 @@ ], "license": "MIT" }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -8355,15 +7651,6 @@ "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/node-releases": { "version": "2.0.51", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", @@ -8383,18 +7670,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -8418,27 +7693,6 @@ "node": ">=14.0.0" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -8637,15 +7891,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8660,6 +7905,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" @@ -8698,16 +7944,6 @@ "node": "20 || >=22" } }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -8800,15 +8036,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", @@ -9042,19 +8269,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9065,56 +8279,12 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, - "node_modules/range-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", - "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -9629,22 +8799,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -9666,12 +8820,6 @@ "node": ">=10" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -9691,61 +8839,11 @@ "node": ">=10" } }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, "node_modules/shebang-command": { "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" @@ -9758,83 +8856,12 @@ "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" } }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -9911,15 +8938,6 @@ "dev": true, "license": "MIT" }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -10248,15 +9266,6 @@ "node": ">=14.0.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -10930,37 +9939,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "license": "MIT", - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -11110,15 +10088,6 @@ "node": ">= 10.0.0" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/unplugin": { "version": "2.3.11", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", @@ -11274,15 +10243,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -11559,6 +10519,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" @@ -11597,12 +10558,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, "node_modules/ws": { "version": "8.21.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", @@ -11677,24 +10632,14 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peer": true, - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } - }, "node_modules/zod-validation-error": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", diff --git a/clients/web/package.json b/clients/web/package.json index a347376e5..cc0de3771 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -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", diff --git a/clients/web/server/vite-base-config.ts b/clients/web/server/vite-base-config.ts index aa4b149fc..c351f4732 100644 --- a/clients/web/server/vite-base-config.ts +++ b/clients/web/server/vite-base-config.ts @@ -28,7 +28,9 @@ const NODE_ONLY_OPTIMIZE_DEPS_EXCLUDE = [ "which", // `@napi-rs/keyring` is loaded only inside // `core/auth/node/secret-store.ts` from the Hono `/api/servers` - // handlers. It's a native-binding package (no browser code path) so + // handlers — and lazily even there, via a cached dynamic import, so + // an unsupported platform degrades instead of crashing at startup + // (#1905). It's a native-binding package (no browser code path) so // excluding it keeps Vite's dep scanner from chasing into the // platform-specific binaries during dev startup. "@napi-rs/keyring", diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 6d162c7bd..509fc0799 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -994,24 +994,33 @@ function App() { } = useInspectorClient(inspectorClient); const { tools: managedTools, + error: toolsLoadError, listChanged: toolsListChanged, refresh: refreshTools, clearListChanged: clearToolsListChanged, } = useManagedTools(inspectorClient, managedToolsState); const { prompts: managedPrompts, + error: promptsLoadError, listChanged: promptsListChanged, refresh: refreshPrompts, clearListChanged: clearPromptsListChanged, } = useManagedPrompts(inspectorClient, managedPromptsState); const { resources: managedResources, + error: resourcesLoadError, listChanged: resourcesListChanged, refresh: refreshResources, clearListChanged: clearResourcesListChanged, } = useManagedResources(inspectorClient, managedResourcesState); - const { resourceTemplates, refresh: refreshResourceTemplates } = - useManagedResourceTemplates(inspectorClient, managedResourceTemplatesState); + const { + resourceTemplates, + error: resourceTemplatesLoadError, + refresh: refreshResourceTemplates, + } = useManagedResourceTemplates( + inspectorClient, + managedResourceTemplatesState, + ); // Paged (paginated) list sources. When `paginatedLists` is on the managed // states skip their all-page walk and these drive the sidebar instead (#1721). const { @@ -4373,6 +4382,9 @@ function App() { toolsListChanged={toolsListChanged} promptsListChanged={promptsListChanged} resourcesListChanged={resourcesListChanged} + toolsLoadError={toolsLoadError} + promptsLoadError={promptsLoadError} + resourcesLoadError={resourcesLoadError ?? resourceTemplatesLoadError} subscriptions={subscriptions} subscriptionStreamState={subscriptionStreamState} logs={logs} diff --git a/clients/web/src/components/elements/ListLoadError/ListLoadError.stories.tsx b/clients/web/src/components/elements/ListLoadError/ListLoadError.stories.tsx new file mode 100644 index 000000000..de876a61c --- /dev/null +++ b/clients/web/src/components/elements/ListLoadError/ListLoadError.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { ListLoadError } from "./ListLoadError"; + +const meta: Meta<typeof ListLoadError> = { + title: "Elements/ListLoadError", + component: ListLoadError, +}; + +export default meta; +type Story = StoryObj<typeof ListLoadError>; + +/** The failure this was built for: a modern server returning a list result the + * SDK codec rejects (#1953). */ +export const CodecRejection: Story = { + args: { + what: "tools", + error: new Error( + 'Invalid result for tools/list: [\n {\n "expected": "number",\n "code": "invalid_type",\n "path": [\n "ttlMs"\n ]\n }\n]', + ), + onRetry: fn(), + }, +}; + +export const TransportFailure: Story = { + args: { + what: "prompts", + error: new Error("fetch failed: ECONNREFUSED 127.0.0.1:3100"), + onRetry: fn(), + }, +}; + +/** No retry handler — the alert renders without the affordance. */ +export const WithoutRetry: Story = { + args: { + what: "resources", + error: new Error("Request timed out"), + }, +}; + +/** The resting state: no error, nothing rendered. */ +export const NoError: Story = { + args: { + what: "tools", + error: null, + onRetry: fn(), + }, +}; diff --git a/clients/web/src/components/elements/ListLoadError/ListLoadError.test.tsx b/clients/web/src/components/elements/ListLoadError/ListLoadError.test.tsx new file mode 100644 index 000000000..7ec251746 --- /dev/null +++ b/clients/web/src/components/elements/ListLoadError/ListLoadError.test.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { ListLoadError } from "./ListLoadError"; + +describe("ListLoadError", () => { + it("renders nothing when there is no error", () => { + renderWithMantine(<ListLoadError error={null} what="tools" />); + expect(screen.queryByText(/Couldn't load/)).not.toBeInTheDocument(); + }); + + it("renders nothing when the error prop is omitted", () => { + renderWithMantine(<ListLoadError what="tools" />); + expect(screen.queryByText(/Couldn't load/)).not.toBeInTheDocument(); + }); + + it("names what failed to load and shows the reason verbatim", () => { + // The exact text is the diagnostic — a validation failure names the JSON + // path and what was expected, so it is rendered unabridged (#1953). + const message = 'Invalid result for tools/list: [{"path":["ttlMs"]}]'; + renderWithMantine( + <ListLoadError error={new Error(message)} what="tools" />, + ); + + expect(screen.getByText("Couldn't load tools")).toBeInTheDocument(); + expect(screen.getByText(message)).toBeInTheDocument(); + }); + + it("uses the given noun so each list names itself", () => { + renderWithMantine(<ListLoadError error={new Error("x")} what="prompts" />); + expect(screen.getByText("Couldn't load prompts")).toBeInTheDocument(); + }); + + it("invokes onRetry when Retry is clicked", async () => { + const user = userEvent.setup(); + const onRetry = vi.fn(); + renderWithMantine( + <ListLoadError error={new Error("x")} what="tools" onRetry={onRetry} />, + ); + + await user.click(screen.getByRole("button", { name: "Retry" })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it("omits the retry affordance when no handler is given", () => { + renderWithMantine(<ListLoadError error={new Error("x")} what="tools" />); + expect( + screen.queryByRole("button", { name: "Retry" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/elements/ListLoadError/ListLoadError.tsx b/clients/web/src/components/elements/ListLoadError/ListLoadError.tsx new file mode 100644 index 000000000..fdb4ef839 --- /dev/null +++ b/clients/web/src/components/elements/ListLoadError/ListLoadError.tsx @@ -0,0 +1,65 @@ +import { Alert, Button, Code, ScrollArea, Stack } from "@mantine/core"; + +export interface ListLoadErrorProps { + /** + * The failed load's error, or `null`/`undefined` when the last load + * succeeded (renders nothing). + */ + error?: Error | null; + /** What failed to load, for the alert title — e.g. "tools", "prompts". */ + what: string; + /** Retry the load. Omit to render the alert without a retry affordance. */ + onRetry?: () => void; +} + +// `variant="light"` + red: an error the user can act on (retry), not a fatal +// one. Sits above the list rather than replacing it — a stale list plus a +// visible "this didn't reload" beats an empty panel that looks like an answer. +const ErrorAlert = Alert.withProps({ + color: "red", + variant: "light", +}); + +// The raw message, monospaced and wrapping: these are validation failures +// (JSON paths, schema expectations) where the exact text is the diagnostic. +const ErrorMessage = Code.withProps({ + block: true, + variant: "wrapping", +}); + +// Caps the message: a schema-validation failure serializes to a dozen-plus +// lines, which would otherwise push the list itself off the sidebar. +const MessageScroll = ScrollArea.withProps({ + mah: 180, + type: "auto", +}); + +const RetryButton = Button.withProps({ + size: "xs", + variant: "light", + color: "red", + w: "fit-content", +}); + +/** + * The list panel's "couldn't load" state (#1953). + * + * A list fetch that fails — a transport error, or a result the SDK codec + * rejects as invalid for the negotiated protocol era — used to leave the panel + * empty, which is indistinguishable from a server that legitimately has no + * tools/prompts/resources. This says what happened and offers a retry. + */ +export function ListLoadError({ error, what, onRetry }: ListLoadErrorProps) { + if (!error) return null; + + return ( + <ErrorAlert title={`Couldn't load ${what}`}> + <Stack gap="xs"> + <MessageScroll> + <ErrorMessage>{error.message}</ErrorMessage> + </MessageScroll> + {onRetry && <RetryButton onClick={onRetry}>Retry</RetryButton>} + </Stack> + </ErrorAlert> + ); +} diff --git a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx index 0c40d0ae1..effbd5a20 100644 --- a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx +++ b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx @@ -149,4 +149,28 @@ describe("AppDetailPanel", () => { await user.click(screen.getByRole("button", { name: /open app/i })); expect(onOpenApp).toHaveBeenCalledTimes(1); }); + + it("drops a previous app's in-progress number text when the app changes", async () => { + const user = userEvent.setup(); + // AppsScreen swaps selectedAppName + formValues in place rather than + // remounting this panel, so two apps exposing a same-named number field + // share the field component. Both values here are undefined, which the + // draft/value re-sync cannot distinguish — only the resetKey identity can. + const numberFieldTool = (name: string): Tool => ({ + name, + title: name, + inputSchema: { + type: "object", + properties: { scale: { type: "number", title: "Scale" } }, + }, + }); + const { rerender } = renderWithMantine( + <AppDetailPanel {...baseProps} tool={numberFieldTool("app_a")} />, + ); + const input = () => screen.getByLabelText(/Scale/) as HTMLInputElement; + await user.type(input(), "-"); + expect(input().value).toBe("-"); + rerender(<AppDetailPanel {...baseProps} tool={numberFieldTool("app_b")} />); + expect(input().value).toBe(""); + }); }); diff --git a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx index 51bf14c62..8d5a2135d 100644 --- a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx +++ b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx @@ -76,6 +76,12 @@ export function AppDetailPanel({ values={formValues} onChange={onFormChange} disabled={isOpening} + // Like ToolDetailPanel, this panel is reused across app selections + // rather than remounted (AppsScreen.handleSelect swaps + // selectedAppName + formValues in place), so the form needs the app + // tool's name to drop another app's in-progress field text. See + // SchemaFormProps.resetKey. + resetKey={tool.name} /> <OpenAppButton diff --git a/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx b/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx index 570fe040c..3fef26539 100644 --- a/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx +++ b/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx @@ -160,4 +160,29 @@ describe("PromptControls", () => { expect(screen.getByText("Prompts")).toBeInTheDocument(); expect(screen.queryByText("translate")).not.toBeInTheDocument(); }); + + // A failed load is rendered above the list instead of leaving the panel + // empty, which is indistinguishable from a server that has none (#1953). + it("renders a failed load above the list and retries via onRefreshList", async () => { + const user = userEvent.setup(); + const onRefreshList = vi.fn(); + renderWithMantine( + <PromptControls + {...baseProps} + loadError={new Error("codec said no")} + onRefreshList={onRefreshList} + />, + ); + + expect(screen.getByText("Couldn't load prompts")).toBeInTheDocument(); + expect(screen.getByText("codec said no")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Retry" })); + expect(onRefreshList).toHaveBeenCalledTimes(1); + }); + + it("renders no load error by default", () => { + renderWithMantine(<PromptControls {...baseProps} />); + expect(screen.queryByText(/Couldn't load/)).not.toBeInTheDocument(); + }); }); diff --git a/clients/web/src/components/groups/PromptControls/PromptControls.tsx b/clients/web/src/components/groups/PromptControls/PromptControls.tsx index e99f0e619..2a2d5364c 100644 --- a/clients/web/src/components/groups/PromptControls/PromptControls.tsx +++ b/clients/web/src/components/groups/PromptControls/PromptControls.tsx @@ -2,6 +2,7 @@ import { Group, ScrollArea, Stack, TextInput, Title } from "@mantine/core"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; import type { Prompt } from "@modelcontextprotocol/client"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; +import { ListLoadError } from "../../elements/ListLoadError/ListLoadError"; import { ListPaginationControls, type ListPaginationControlsProps, @@ -36,6 +37,11 @@ export interface PromptControlsProps { searchText?: string; listChanged: boolean; onRefreshList: () => void; + /** + * A failed list load, surfaced above the list instead of leaving the panel + * empty (which reads as "this server has none") (#1953). + */ + loadError?: Error | null; /** Pagination controls (#1721). */ pagination: ListPaginationControlsProps; onSearchChange: (value: string) => void; @@ -48,6 +54,7 @@ export function PromptControls({ searchText = "", listChanged, onRefreshList, + loadError, pagination, onSearchChange, onSelectPrompt, @@ -75,6 +82,7 @@ export function PromptControls({ } /> <ListPaginationControls {...pagination} /> + <ListLoadError error={loadError} what="prompts" onRetry={onRefreshList} /> <ListScroll viewportRef={viewportRef}> <Stack gap="xs"> {filteredPrompts.map((prompt) => ( diff --git a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx index 070e769bd..45d658c42 100644 --- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx +++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx @@ -711,3 +711,92 @@ describe("ProtocolEntry — modern vocabulary", () => { }); }); }); + +// A response the server answered successfully but the CLIENT then refused — +// e.g. the SDK's era codec rejecting a 2026-07-28 list result missing +// `ttlMs`/`cacheScope`. The wire frame is a valid JSON-RPC result, so before +// #1953 this rendered as a clean success. +describe("ProtocolEntry — client-rejected response", () => { + const REASON = "Invalid result for tools/list: ttlMs required"; + + const rejectedEntry: MessageEntry = { + id: "rej-1", + timestamp: new Date("2026-07-28T10:00:00Z"), + direction: "request", + origin: "client", + message: { jsonrpc: "2.0", id: 9, method: "tools/list", params: {} }, + response: { + jsonrpc: "2.0", + id: 9, + result: { resultType: "complete", tools: [] }, + }, + clientError: REASON, + }; + + it("renders Error rather than a success status", () => { + renderWithMantine(<ProtocolEntry {...baseProps} entry={rejectedEntry} />); + expect(screen.getByText("Error")).toBeInTheDocument(); + expect(screen.queryByText("OK")).not.toBeInTheDocument(); + }); + + it("shows the Error badge even though a resultType badge is present", () => { + // The resultType normally suppresses the status badge (a modern success + // says "complete"). A rejected result must not hide behind it — the wire + // said complete, the client disagreed, and both are worth seeing. + renderWithMantine(<ProtocolEntry {...baseProps} entry={rejectedEntry} />); + expect(screen.getByText("complete")).toBeInTheDocument(); + expect(screen.getByText("Error")).toBeInTheDocument(); + }); + + it("names the Inspector as the rejecter and gives the reason when expanded", () => { + renderWithMantine( + <ProtocolEntry {...baseProps} entry={rejectedEntry} isListExpanded />, + ); + expect(screen.getByText("Rejected by the Inspector")).toBeInTheDocument(); + expect(screen.getByText(REASON)).toBeInTheDocument(); + }); + + // messageLogState falls back to the standalone response frame when no request + // entry was there to fold into (a trimmed log, or a reconnect boundary). That + // entry is not `direction: "request"`, so the request-only status lifecycle + // would have rendered it without any badge at all. + const rejectedStandaloneResponse: MessageEntry = { + id: "rej-standalone", + timestamp: new Date("2026-07-28T10:00:00Z"), + direction: "response", + origin: "server", + message: { + jsonrpc: "2.0", + id: 11, + result: { resultType: "complete", tools: [] }, + }, + clientError: REASON, + }; + + it("renders Error on a rejected standalone response entry", () => { + renderWithMantine( + <ProtocolEntry {...baseProps} entry={rejectedStandaloneResponse} />, + ); + expect(screen.getByText("Error")).toBeInTheDocument(); + }); + + it("still renders no status badge on an unrejected standalone response", () => { + const clean: MessageEntry = { + ...rejectedStandaloneResponse, + clientError: undefined, + }; + renderWithMantine(<ProtocolEntry {...baseProps} entry={clean} />); + expect(screen.queryByText("Error")).not.toBeInTheDocument(); + expect(screen.queryByText("OK")).not.toBeInTheDocument(); + expect(screen.queryByText("Pending")).not.toBeInTheDocument(); + }); + + it("shows no rejection alert on an ordinary success", () => { + renderWithMantine( + <ProtocolEntry {...baseProps} entry={successEntry} isListExpanded />, + ); + expect( + screen.queryByText("Rejected by the Inspector"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx index c7cb52f39..73edda6cd 100644 --- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx +++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx @@ -135,6 +135,16 @@ const SpecErrorAlert = Alert.withProps({ icon: <RiErrorWarningLine />, }); +// The client rejected an otherwise well-formed response (#1953). Distinct from +// SpecErrorAlert: nothing is wrong with the server's JSON-RPC frame — the +// Inspector's own decoding refused the result — so the title says who rejected it. +const ClientErrorAlert = Alert.withProps({ + variant: "light", + color: "red", + icon: <RiErrorWarningLine />, + title: "Rejected by the Inspector", +}); + // Link (button-styled) that jumps to the correlated HTTP entry in the Network tab. const RevealLink = Anchor.withProps({ component: "button", @@ -210,6 +220,13 @@ function extractResourceUri(entry: MessageEntry): string | undefined { function extractStatus( entry: MessageEntry, ): "success" | "error" | "pending" | "none" { + // A response the CLIENT refused is an error whichever entry carries it, so + // this is checked BEFORE the request-only lifecycle below (#1953). + // messageLogState annotates the request entry when the response was folded + // into one, but falls back to the standalone response frame when there was + // no matching request (a trimmed log, or a reconnect boundary) — and that + // entry would otherwise fall straight through to "none" and render no badge. + if (entry.clientError) return "error"; if (entry.direction !== "request") return "none"; if (!entry.response) return "pending"; if ("error" in entry.response) return "error"; @@ -330,11 +347,12 @@ export function ProtocolEntry({ // Suppress the redundant green "OK" when a `resultType` badge already conveys // the outcome (a modern success is `complete`/`input required`); errors and // pending have no `resultType`, so their status badge still shows. - const statusBadge = status !== "none" && !resultType && ( - <Badge color={statusColor(status)} variant="status"> - {statusLabel(status)} - </Badge> - ); + const statusBadge = status !== "none" && + (!resultType || entry.clientError) && ( + <Badge color={statusColor(status)} variant="status"> + {statusLabel(status)} + </Badge> + ); const subscriptionBadge = subscriptionId && ( <SubscriptionCluster> <SubscriptionLabel>sub</SubscriptionLabel> @@ -431,6 +449,11 @@ export function ProtocolEntry({ <Collapse in={isExpanded}> <Stack gap="sm"> <Divider /> + {entry.clientError && ( + <ClientErrorAlert> + <Text size="xs">{entry.clientError}</Text> + </ClientErrorAlert> + )} {specError && ( <McpSpecErrorAlert error={specError} diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx index c920fb320..86069af61 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx @@ -395,4 +395,29 @@ describe("ResourceControls", () => { expect(screen.queryByText("Listening")).not.toBeInTheDocument(); }); }); + + // A failed load is rendered above the list instead of leaving the panel + // empty, which is indistinguishable from a server that has none (#1953). + it("renders a failed load above the list and retries via onRefreshList", async () => { + const user = userEvent.setup(); + const onRefreshList = vi.fn(); + renderWithMantine( + <ResourceControls + {...baseProps} + loadError={new Error("codec said no")} + onRefreshList={onRefreshList} + />, + ); + + expect(screen.getByText("Couldn't load resources")).toBeInTheDocument(); + expect(screen.getByText("codec said no")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Retry" })); + expect(onRefreshList).toHaveBeenCalledTimes(1); + }); + + it("renders no load error by default", () => { + renderWithMantine(<ResourceControls {...baseProps} />); + expect(screen.queryByText(/Couldn't load/)).not.toBeInTheDocument(); + }); }); diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx index 9a2d992f0..adf5aa1a5 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx @@ -13,6 +13,7 @@ import type { import { isModernEra } from "../../elements/EraBadge/eraUtils"; import { SubscriptionStreamBadge } from "../../elements/SubscriptionStreamBadge/SubscriptionStreamBadge"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; +import { ListLoadError } from "../../elements/ListLoadError/ListLoadError"; import { ListPaginationControls, type ListPaginationControlsProps, @@ -51,7 +52,9 @@ export interface ResourceControlsProps { * Modern-era `subscriptions/listen` stream state (#1630). When `active` * (modern era with at least one subscription) the Subscriptions section shows * a stream-status badge in its panel and a status dot in its header. Legacy - * connections pass `active: false` (or omit it) and see neither. + * connections pass `active: false` (or omit it) and see neither — and so does + * a stream open purely for list-change notifications, which this section has + * nothing to say about (#1920). */ subscriptionStreamState?: ResourceSubscriptionStreamState; /** Negotiated protocol era; gates the modern subscription stream chrome. */ @@ -66,6 +69,11 @@ export interface ResourceControlsProps { openSections?: string[]; listChanged: boolean; onRefreshList: () => void; + /** + * A failed list load, surfaced above the list instead of leaving the panel + * empty (which reads as "this server has none") (#1953). + */ + loadError?: Error | null; /** Pagination controls for the Resources list (#1721). */ pagination: ListPaginationControlsProps; onSearchChange: (value: string) => void; @@ -109,6 +117,7 @@ export function ResourceControls({ openSections: controlledOpenSections, listChanged, onRefreshList, + loadError, pagination, onSearchChange, onOpenSectionsChange, @@ -234,6 +243,11 @@ export function ResourceControls({ <ListToggle compact={!allExpanded} onToggle={handleToggleList} /> </TightRow> <ListPaginationControls {...pagination} /> + <ListLoadError + error={loadError} + what="resources" + onRetry={onRefreshList} + /> {/* Stays inline: Accordion is a compound, `multiple`-discriminated generic, so `.withProps({ multiple: true, ... })` loses its JSX call signature (same tooling limit as Box). */} diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index c534fb29f..5b9f064ae 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -1,7 +1,12 @@ +import { useState } from "react"; import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import type { InspectorFormSchema } from "../../../utils/jsonUtils"; -import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { + fireEvent, + renderWithMantine, + screen, +} from "../../../test/renderWithMantine"; import { SchemaForm } from "./SchemaForm"; describe("SchemaForm", () => { @@ -228,6 +233,238 @@ describe("SchemaForm", () => { expect(lastCall.count).toBeUndefined(); }); + describe("number fields (#1888)", () => { + // The real callers keep the form values in state and feed them back in, so + // the bug only reproduces against a genuinely controlled SchemaForm: an + // uncontrolled render never rewrites the box and would pass either way. + function ControlledSchemaForm({ + schema, + initialValues = {}, + onChange, + }: { + schema: InspectorFormSchema; + initialValues?: Record<string, unknown>; + onChange: (values: Record<string, unknown>) => void; + }) { + const [values, setValues] = + useState<Record<string, unknown>>(initialValues); + return ( + <SchemaForm + schema={schema} + values={values} + onChange={(next) => { + setValues(next); + onChange(next); + }} + /> + ); + } + + const numberSchema: InspectorFormSchema = { + type: "object", + properties: { + divisor: { type: "number", title: "Divisor" }, + }, + }; + + it("lets a decimal be typed all the way through", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithMantine( + <ControlledSchemaForm schema={numberSchema} onChange={onChange} />, + ); + const input = screen.getByLabelText(/Divisor/) as HTMLInputElement; + await user.type(input, "1.5"); + expect(input.value).toBe("1.5"); + expect(onChange).toHaveBeenLastCalledWith({ divisor: 1.5 }); + }); + + it("keeps the trailing decimal point visible mid-entry", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithMantine( + <ControlledSchemaForm schema={numberSchema} onChange={onChange} />, + ); + const input = screen.getByLabelText(/Divisor/) as HTMLInputElement; + await user.type(input, "1."); + // The point survives on screen even though "1." parses to plain 1. + expect(input.value).toBe("1."); + expect(onChange).toHaveBeenLastCalledWith({ divisor: 1 }); + }); + + it("keeps a trailing zero after the decimal point", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithMantine( + <ControlledSchemaForm schema={numberSchema} onChange={onChange} />, + ); + const input = screen.getByLabelText(/Divisor/) as HTMLInputElement; + await user.type(input, "1.50"); + expect(input.value).toBe("1.50"); + expect(onChange).toHaveBeenLastCalledWith({ divisor: 1.5 }); + }); + + it("reports a lone minus sign as no value while leaving it typed", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithMantine( + <ControlledSchemaForm schema={numberSchema} onChange={onChange} />, + ); + const input = screen.getByLabelText(/Divisor/) as HTMLInputElement; + await user.type(input, "-"); + expect(input.value).toBe("-"); + expect(onChange).toHaveBeenLastCalledWith({ divisor: undefined }); + await user.type(input, "2.5"); + expect(onChange).toHaveBeenLastCalledWith({ divisor: -2.5 }); + }); + + it("rejects a decimal point in an integer field", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + count: { type: "integer", title: "Count" }, + }, + }; + renderWithMantine( + <ControlledSchemaForm schema={schema} onChange={onChange} />, + ); + const input = screen.getByLabelText(/Count/) as HTMLInputElement; + await user.type(input, "1.5"); + expect(input.value).toBe("15"); + expect(onChange).toHaveBeenLastCalledWith({ count: 15 }); + }); + + it("reports no value for a magnitude JS cannot hold exactly", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithMantine( + <ControlledSchemaForm schema={numberSchema} onChange={onChange} />, + ); + const input = screen.getByLabelText(/Divisor/) as HTMLInputElement; + // Past Number.MAX_SAFE_INTEGER, Mantine stops emitting a number and hands + // back the raw string to avoid destroying precision. Number() would round + // this to ...904, so parsing it would send a value the user never typed. + await user.type(input, "90071992547409910"); + expect(input.value).toBe("90071992547409910"); + expect(onChange).toHaveBeenLastCalledWith({ divisor: undefined }); + }); + + it("still parses a long decimal, which stays exactly representable", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithMantine( + <ControlledSchemaForm schema={numberSchema} onChange={onChange} />, + ); + const input = screen.getByLabelText(/Divisor/) as HTMLInputElement; + // Guards the safe-integer check against over-rejecting: the fractional + // digits are not what overflows, so this must not be dropped. + await user.type(input, "3.14159265358979"); + expect(onChange).toHaveBeenLastCalledWith({ divisor: 3.14159265358979 }); + }); + + it("drops in-progress text when resetKey moves the form to another entity", async () => { + const user = userEvent.setup(); + // Both "tools" expose the same-named number field with no default, so the + // value is `undefined` before and after the switch. The value comparison + // sees no divergence, and only resetKey can tell the field to start over. + const schema: InspectorFormSchema = { + type: "object", + properties: { divisor: { type: "number", title: "Divisor" } }, + }; + function Harness() { + const [tool, setTool] = useState("tool-a"); + const [values, setValues] = useState<Record<string, unknown>>({}); + return ( + <> + <button + type="button" + onClick={() => { + setTool("tool-b"); + // What ToolsScreen does on select: replace the form values. + setValues({}); + }} + > + Switch tool + </button> + <SchemaForm + schema={schema} + values={values} + onChange={setValues} + resetKey={tool} + /> + </> + ); + } + renderWithMantine(<Harness />); + const input = () => screen.getByLabelText(/Divisor/) as HTMLInputElement; + await user.type(input(), "-"); + expect(input().value).toBe("-"); + // fireEvent, not user.click: a real click also blurs the input, and + // Mantine sanitizes an incomplete value on blur — which would mask + // whether the switch itself cleared the draft. This drives the state + // change without the blur, isolating the reset to resetKey. + fireEvent.click(screen.getByRole("button", { name: "Switch tool" })); + expect(input().value).toBe(""); + }); + + it("keeps in-progress text across re-renders of the same entity", async () => { + const user = userEvent.setup(); + // The counterpart to the test above: a stable resetKey must NOT remount + // the field, or every keystroke would wipe the draft and reinstate #1888. + const schema: InspectorFormSchema = { + type: "object", + properties: { divisor: { type: "number", title: "Divisor" } }, + }; + function Harness() { + const [values, setValues] = useState<Record<string, unknown>>({}); + return ( + <SchemaForm + schema={schema} + values={values} + onChange={setValues} + resetKey="tool-a" + /> + ); + } + renderWithMantine(<Harness />); + const input = screen.getByLabelText(/Divisor/) as HTMLInputElement; + await user.type(input, "1.5"); + expect(input.value).toBe("1.5"); + }); + + it("re-syncs the displayed text when the value changes externally", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + function Harness() { + const [values, setValues] = useState<Record<string, unknown>>({ + divisor: 1.5, + }); + return ( + <> + <button type="button" onClick={() => setValues({ divisor: 42 })}> + Load example + </button> + <SchemaForm + schema={numberSchema} + values={values} + onChange={(next) => { + setValues(next); + onChange(next); + }} + /> + </> + ); + } + renderWithMantine(<Harness />); + const input = screen.getByLabelText(/Divisor/) as HTMLInputElement; + expect(input.value).toBe("1.5"); + await user.click(screen.getByRole("button", { name: "Load example" })); + expect(input.value).toBe("42"); + }); + }); + it("falls back to empty/const labels for oneOf items missing const and title", () => { const onChange = vi.fn(); const schema: InspectorFormSchema = { diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 30e39795e..232b5327a 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -8,7 +8,9 @@ import { Text, TextInput, } from "@mantine/core"; +import { useState } from "react"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; +import { useValueChange } from "../../../hooks/useValueChange"; import type { InspectorFormSchema } from "../../../utils/jsonUtils"; const FieldLabel = Text.withProps({ @@ -49,11 +51,129 @@ function toEnumData( return values; } +/** + * Interpret whatever Mantine's `NumberInput` reported as the JSON value for the + * field. Anything that is not a finite number becomes `undefined`, which is how + * an absent optional argument is represented everywhere else in this form. + * + * `NumberInput` emits a `number` only when the text both parses *and* is exactly + * representable; otherwise it hands back the **raw string** (see its + * `isValidNumber` guard). Two quite different situations produce a string, and + * they are treated differently here: + * + * 1. **Mid-entry text** — `""` when cleared, plus `"1."`, `"1.50"`, and a lone + * `"-"`. These are parsed: `"1."` really does mean `1`. (Note that an + * exponent is *not* in this set — `NumberInput` masks input through + * `NumericFormat`, which rejects `e` outright, so `"1e"` can never be typed.) + * 2. **Values JS cannot hold exactly** — anything at or beyond + * `Number.MAX_SAFE_INTEGER`. `Number("90071992547409910")` silently yields + * `90071992547409904`, so parsing here would send the server a number the + * user never entered. An inspector must not misreport what it transmits, so + * these report no value instead — which is also what this field did with such + * input before #1888, making it no regression. Preserving them properly needs + * an exact-serialization path down the whole `tools/call` chain, which is a + * separate concern from being able to type a decimal. + */ +function toNumericValue(raw: string | number): number | undefined { + if (typeof raw === "number") { + return Number.isFinite(raw) ? raw : undefined; + } + if (raw.trim() === "") { + return undefined; + } + const parsed = Number(raw); + if (!Number.isFinite(parsed)) { + return undefined; + } + // Case 2 above. The integer part is what overflows exact representation; the + // fractional digits are bounded by the same guard and stay lossless. + return Number.isSafeInteger(Math.trunc(parsed)) ? parsed : undefined; +} + +interface SchemaNumberInputProps { + label: string; + description?: string; + withAsterisk: boolean; + disabled: boolean; + value: number | undefined; + min?: number; + max?: number; + allowDecimal: boolean; + onChange: (value: number | undefined) => void; +} + +/** + * A `NumberInput` that keeps the text the user is typing, not just the number it + * currently parses to. + * + * Driving `NumberInput` directly off the parent's numeric value makes a decimal + * impossible to enter (#1888): typing `.` after `1` produces the unparseable + * string `"1."`, the numeric value stays `1`, and the controlled `value` prop + * immediately rewrites the box back to `"1"` — so the `.` vanishes and `1.5` can + * never be reached. Trailing zeros (`"1.50"`) and a lone leading `"-"` fail the + * same way. + * + * So the raw text is held here as the source of truth for what is *displayed*, + * while the parent still only ever sees a `number | undefined`. The two are + * re-synced only when the parent's value genuinely diverges from what the draft + * parses to, which leaves an external reset (a cleared form, a loaded example) + * working while an in-progress `"1."` — whose parse is `1`, matching the value we + * just emitted — is left alone. + * + * That value comparison cannot see a reset to an *equal* value, so the caller is + * additionally expected to vary this component's React key via `SchemaForm`'s + * `resetKey` when it switches which entity the form edits. See the note on that + * prop for the case it covers. + */ +function SchemaNumberInput({ + value, + onChange, + ...inputProps +}: SchemaNumberInputProps) { + const [draft, setDraft] = useState<string | number>(value ?? ""); + + useValueChange(value, (next) => { + if (!Object.is(toNumericValue(draft), next)) { + setDraft(next ?? ""); + } + }); + + return ( + <NumberInput + {...inputProps} + value={draft} + onChange={(next) => { + setDraft(next); + onChange(toNumericValue(next)); + }} + /> + ); +} + export interface SchemaFormProps { schema: InspectorFormSchema; values: Record<string, unknown>; onChange: (values: Record<string, unknown>) => void; disabled?: boolean; + /** + * Stable identity of whatever this form is editing — a tool name, a request + * id. Pass it whenever the same mounted form is reused for a *different* + * entity, which is the case for the Tools tab: `ToolDetailPanel` is not keyed + * by tool, so selecting another tool re-renders the same field components. + * + * It exists because the number field's draft/value re-sync compares parsed + * numbers, and so cannot detect a reset to an equal value. Type `-` (draft + * `"-"`, value `undefined`), then switch to a tool with a same-named number + * field and no default: the value is `undefined` on both sides, no divergence + * is seen, and the stale `-` would otherwise be left in the box for the new + * tool to continue from. Varying `resetKey` remounts the field instead, so no + * in-progress text can outlive the entity it was typed into. + * + * Omit it when the form is mounted fresh per entity (the elicitation panels), + * where unmounting already discards the draft. The schema object itself is no + * substitute — callers rebuild it every render, so its identity is unstable. + */ + resetKey?: string; } function getDefaultValue(fieldSchema: InspectorFormSchema): unknown { @@ -78,6 +198,7 @@ export function SchemaForm({ values, onChange, disabled = false, + resetKey, }: SchemaFormProps) { const properties = schema.properties ?? {}; const requiredFields = schema.required ?? []; @@ -156,19 +277,21 @@ export function SchemaForm({ // number or integer if (fieldSchema.type === "number" || fieldSchema.type === "integer") { return ( - <NumberInput - key={fieldName} + <SchemaNumberInput + // The only field holding local state, so the only one that has to be + // remounted when `resetKey` says the form moved to another entity. + key={resetKey === undefined ? fieldName : `${resetKey}:${fieldName}`} label={label} description={description} withAsterisk={isRequired} disabled={disabled} - value={(rawValue as number) ?? ""} + value={rawValue as number | undefined} min={fieldSchema.minimum} max={fieldSchema.maximum} - onChange={(val) => { - const numericValue = typeof val === "string" ? undefined : val; - handleFieldChange(fieldName, numericValue); - }} + // An `integer` field rejects the decimal point outright rather than + // accepting a value the schema forbids. + allowDecimal={fieldSchema.type === "number"} + onChange={(val) => handleFieldChange(fieldName, val)} /> ); } @@ -243,6 +366,8 @@ export function SchemaForm({ handleFieldChange(fieldName, nestedValues) } disabled={disabled} + // Sub-fields belong to the same entity, so they reset with it. + resetKey={resetKey} /> </IndentedStack> </Stack> diff --git a/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.stories.tsx b/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.stories.tsx new file mode 100644 index 000000000..c66760f00 --- /dev/null +++ b/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.stories.tsx @@ -0,0 +1,109 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import { StructuredOutputPanel } from "./StructuredOutputPanel"; + +const nested = { + items: [ + { id: 1, name: "Item A", tags: ["foo", "bar"] }, + { id: 2, name: "Item B", tags: ["baz"] }, + ], + total: 2, +}; + +const flat = { + temperature: 25, + unit: "C", + city: "San Francisco", +}; + +// Long enough that the payload exceeds the section's max height and scrolls +// within it rather than pushing the rest of the result panel down. +const large = { + rows: Array.from({ length: 60 }, (_, index) => ({ + id: index + 1, + label: `Row ${index + 1}`, + value: index * 7, + })), + total: 60, +}; + +const meta: Meta<typeof StructuredOutputPanel> = { + title: "Groups/StructuredOutputPanel", + component: StructuredOutputPanel, +}; + +export default meta; +type Story = StoryObj<typeof StructuredOutputPanel>; + +export const Nested: Story = { + args: { + structuredContent: nested, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("heading", { name: "Structured Output" }), + ).toBeInTheDocument(); + // The section starts expanded, so the payload is visible without a click. + // Asserted on the container's text: once the Prism grammar loads, the JSON + // is split across per-token elements, so no single node holds the value. + await waitFor(() => + expect(canvasElement.textContent).toContain('"Item A"'), + ); + }, +}; + +export const Flat: Story = { + args: { + structuredContent: flat, + }, +}; + +// A payload taller than the section's cap scrolls inside it rather than +// pushing the rest of the result panel down. Asserted on the real geometry, +// so dropping `mah` (or moving it to the wrong element) fails here. +export const Large: Story = { + args: { + structuredContent: large, + }, + play: async ({ canvasElement }) => { + await waitFor(() => + expect(canvasElement.textContent).toContain('"Row 60"'), + ); + const viewport = canvasElement.querySelector( + ".mantine-ScrollArea-viewport", + ); + if (!(viewport instanceof HTMLElement)) { + throw new Error("scroll viewport not found"); + } + // Capped: the visible height stays at the section's `mah`, well under the + // payload's natural height… + expect(viewport.clientHeight).toBeLessThanOrEqual(400); + // …and the overflow is reachable by scrolling rather than clipped away. + expect(viewport.scrollHeight).toBeGreaterThan(viewport.clientHeight); + }, +}; + +// Collapsing hides the payload; expanding brings it back. +export const Collapsed: Story = { + args: { + structuredContent: nested, + defaultExpanded: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const toggle = canvas.getByRole("button", { + name: "Expand structured output", + }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + await userEvent.click(toggle); + await waitFor(() => + expect( + canvas.getByRole("button", { name: "Collapse structured output" }), + ).toHaveAttribute("aria-expanded", "true"), + ); + await waitFor(() => + expect(canvasElement.textContent).toContain('"Item A"'), + ); + }, +}; diff --git a/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.test.tsx b/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.test.tsx new file mode 100644 index 000000000..abdaf91e5 --- /dev/null +++ b/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { StructuredOutputPanel } from "./StructuredOutputPanel"; + +// The real CodeHighlight dynamic-imports the Prism runtime and each grammar +// (its own behavior is covered in CodeHighlight.test.tsx). Stub it so these +// tests assert on the JSON handed to it, synchronously. +vi.mock("../../elements/CodeHighlight/CodeHighlight", () => ({ + CodeHighlight: ({ language, code }: { language: string; code: string }) => ( + <pre data-testid="code-highlight" data-language={language}> + {code} + </pre> + ), +})); + +const structuredContent = { + items: [ + { id: 1, name: "Item A", tags: ["foo", "bar"] }, + { id: 2, name: "Item B", tags: ["baz"] }, + ], + total: 2, +}; + +describe("StructuredOutputPanel", () => { + it("renders a labeled section with the payload as pretty-printed JSON", () => { + renderWithMantine( + <StructuredOutputPanel structuredContent={structuredContent} />, + ); + expect( + screen.getByRole("heading", { name: "Structured Output" }), + ).toBeInTheDocument(); + const code = screen.getByTestId("code-highlight"); + expect(code).toHaveAttribute("data-language", "json"); + expect(code).toHaveTextContent(/"total": 2/); + // Nested values are inspectable field by field, not summarized away. + expect(code).toHaveTextContent(/"name": "Item A"/); + expect(code).toHaveTextContent(/"tags"/); + }); + + it("starts expanded by default", () => { + renderWithMantine( + <StructuredOutputPanel structuredContent={structuredContent} />, + ); + expect( + screen.getByRole("button", { name: "Collapse structured output" }), + ).toHaveAttribute("aria-expanded", "true"); + }); + + it("starts collapsed when defaultExpanded is false", () => { + renderWithMantine( + <StructuredOutputPanel + structuredContent={structuredContent} + defaultExpanded={false} + />, + ); + expect( + screen.getByRole("button", { name: "Expand structured output" }), + ).toHaveAttribute("aria-expanded", "false"); + }); + + it("toggles between expanded and collapsed", async () => { + const user = userEvent.setup(); + renderWithMantine( + <StructuredOutputPanel structuredContent={structuredContent} />, + ); + await user.click( + screen.getByRole("button", { name: "Collapse structured output" }), + ); + const toggle = screen.getByRole("button", { + name: "Expand structured output", + }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + await user.click(toggle); + expect( + screen.getByRole("button", { name: "Collapse structured output" }), + ).toHaveAttribute("aria-expanded", "true"); + }); + + it("renders an empty structured payload rather than nothing", () => { + renderWithMantine(<StructuredOutputPanel structuredContent={{}} />); + expect( + screen.getByRole("heading", { name: "Structured Output" }), + ).toBeInTheDocument(); + expect(screen.getByTestId("code-highlight")).toHaveTextContent("{}"); + }); +}); diff --git a/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.tsx b/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.tsx new file mode 100644 index 000000000..0f088e974 --- /dev/null +++ b/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { + Collapse, + Group, + Paper, + ScrollArea, + Stack, + Title, +} from "@mantine/core"; +import type { CallToolResult } from "@modelcontextprotocol/client"; +import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; +import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; + +export interface StructuredOutputPanelProps { + /** The result's `structuredContent` — the tool's schema-validated payload. */ + structuredContent: NonNullable<CallToolResult["structuredContent"]>; + /** Whether the section starts expanded. Defaults to `true`. */ + defaultExpanded?: boolean; +} + +// Bordered box matching the "Resource Links" group in the result panel, so the +// two supplementary sections of a tool result read as siblings. +const StructuredBox = Paper.withProps({ + withBorder: true, + radius: "md", + p: "md", + variant: "panel", +}); + +const StructuredInner = Stack.withProps({ + gap: "sm", +}); + +const HeaderRow = Group.withProps({ + justify: "space-between", + wrap: "nowrap", +}); + +// h4 (size h5) for the same reason as the "Resource Links" heading: the panel's +// "Results" title is h3, so a sub-box heading is h4 and the heading order never +// skips a level (axe `heading-order`). +const StructuredHeader = Title.withProps({ + order: 4, + size: "h5", +}); + +// Caps the payload so a large structured result scrolls within the box instead +// of pushing the content blocks out of view. `Autosize` sizes to the content up +// to `mah`, so a small object still takes only what it needs. +const StructuredScroll = ScrollArea.Autosize.withProps({ + mah: 400, + type: "auto", + scrollbars: "y", + offsetScrollbars: true, +}); + +/** + * Collapsible "Structured Output" section for a tool result's + * `structuredContent` (#1908). A tool declaring an `outputSchema` returns its + * real payload here — the `content[]` blocks usually only summarize it — so v1 + * rendered it as its own inspectable JSON section. Without this, the payload is + * dropped from the Tools screen entirely, with no hint it was ever returned. + * + * The JSON is pretty-printed and syntax-highlighted through {@link ContentViewer} + * (an `application/json` text block), so it is copyable and scannable field by + * field. + */ +export function StructuredOutputPanel({ + structuredContent, + defaultExpanded = true, +}: StructuredOutputPanelProps) { + const [expanded, setExpanded] = useState(defaultExpanded); + + return ( + <StructuredBox> + <StructuredInner> + <HeaderRow> + <StructuredHeader>Structured Output</StructuredHeader> + <ExpandToggle + expanded={expanded} + onToggle={() => setExpanded((value) => !value)} + ariaLabel={`${expanded ? "Collapse" : "Expand"} structured output`} + /> + </HeaderRow> + {/* Content stays mounted across a collapse (Mantine `Collapse`), so the + highlighted JSON isn't re-rendered from scratch on every toggle. */} + <Collapse in={expanded}> + <StructuredScroll> + <ContentViewer + block={{ + type: "text", + text: JSON.stringify(structuredContent, null, 2), + }} + mimeType="application/json" + copyable + /> + </StructuredScroll> + </Collapse> + </StructuredInner> + </StructuredBox> + ); +} diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx index c586e12c7..b6e379e86 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx @@ -183,4 +183,152 @@ describe("ToolControls", () => { ); expect(screen.getByText("invalid_header_tool")).toBeInTheDocument(); }); + + // A failed load is rendered above the list instead of leaving the panel + // empty, which is indistinguishable from a server that has none (#1953). + it("renders a failed load above the list and retries via onRefreshList", async () => { + const user = userEvent.setup(); + const onRefreshList = vi.fn(); + renderWithMantine( + <ToolControls + {...baseProps} + loadError={new Error("codec said no")} + onRefreshList={onRefreshList} + />, + ); + + expect(screen.getByText("Couldn't load tools")).toBeInTheDocument(); + expect(screen.getByText("codec said no")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Retry" })); + 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(); + }); }); diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.tsx index db0a049a0..4ad70d854 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.tsx @@ -14,6 +14,7 @@ import { ClearButton } from "../../elements/ClearButton/ClearButton"; import type { Tool } from "@modelcontextprotocol/client"; import type { ExcludedTool } from "@inspector/core/mcp/types.js"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; +import { ListLoadError } from "../../elements/ListLoadError/ListLoadError"; import { ListPaginationControls, type ListPaginationControlsProps, @@ -32,6 +33,11 @@ export interface ToolControlsProps { searchText?: string; listChanged: boolean; onRefreshList: () => void; + /** + * A failed list load, surfaced above the list instead of leaving the panel + * empty (which reads as "this server has none") (#1953). + */ + loadError?: Error | null; /** Pagination controls (#1721). */ pagination: ListPaginationControlsProps; onSearchChange: (value: string) => void; @@ -102,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 = [], @@ -109,28 +127,26 @@ export function ToolControls({ searchText = "", listChanged, onRefreshList, + loadError, pagination, onSearchChange, onSelectTool, }: 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> @@ -146,11 +162,12 @@ export function ToolControls({ } /> <ListPaginationControls {...pagination} /> + <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={() => { @@ -161,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 /> diff --git a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx index a74c63a96..fbb6d26d6 100644 --- a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx +++ b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx @@ -317,6 +317,10 @@ export function ToolDetailPanel({ values={formValues} onChange={onFormChange} disabled={isExecuting} + // This panel is reused across tool selections rather than remounted, + // so the form needs the tool name to drop another tool's + // in-progress field text. See SchemaFormProps.resetKey. + resetKey={name} /> {progress && <ProgressDisplay params={progress} />} diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx index 1134f6576..683bb4238 100644 --- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx +++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx @@ -173,6 +173,24 @@ export const ResourceLinksWithLongText: Story = { decorators: fillHeightDecorators, }; +// A tool declaring an `outputSchema` returns its real payload in +// `structuredContent`, which the text block only summarizes (#1908). It gets +// its own collapsible "Structured Output" section below the content blocks. +export const StructuredOutput: Story = { + args: { + result: { + content: [{ type: "text", text: "Found 2 items." }], + structuredContent: { + items: [ + { id: 1, name: "Item A", tags: ["foo", "bar"] }, + { id: 2, name: "Item B", tags: ["baz"] }, + ], + total: 2, + }, + }, + }, +}; + export const ErrorResult: Story = { args: { result: { diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx index 1ebaea7b3..1be9f2c37 100644 --- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx +++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx @@ -109,6 +109,78 @@ describe("ToolResultPanel", () => { expect(screen.getByText("divider")).toBeInTheDocument(); }); + describe("structuredContent (#1908)", () => { + const structured = { items: [{ id: 1, name: "Item A" }], total: 1 }; + + it("renders a Structured Output section alongside the content blocks", () => { + renderWithMantine( + <ToolResultPanel + result={{ ...okResult, structuredContent: structured }} + onClear={() => {}} + />, + ); + expect(screen.getByText("ok")).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Structured Output" }), + ).toBeInTheDocument(); + }); + + it("omits the section when the result has no structuredContent", () => { + renderWithMantine( + <ToolResultPanel result={okResult} onClear={() => {}} />, + ); + expect( + screen.queryByRole("heading", { name: "Structured Output" }), + ).not.toBeInTheDocument(); + }); + + it("renders the section instead of the empty state when content is empty", () => { + renderWithMantine( + <ToolResultPanel + result={{ content: [], structuredContent: structured }} + onClear={() => {}} + />, + ); + expect(screen.queryByText("No results yet")).not.toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Structured Output" }), + ).toBeInTheDocument(); + }); + + it("renders the section below the alert on an error result", () => { + renderWithMantine( + <ToolResultPanel + result={{ ...errorResult, structuredContent: structured }} + onClear={() => {}} + />, + ); + expect(screen.getByText("Tool Error")).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Structured Output" }), + ).toBeInTheDocument(); + }); + + it("renders the section alongside a Resource Links box", () => { + renderWithMantine( + <ToolResultPanel + result={{ + content: [ + { type: "resource_link", uri: "demo://r/1", name: "One" }, + ], + structuredContent: structured, + }} + onClear={() => {}} + />, + ); + expect( + screen.getByRole("heading", { name: "Resource Links" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Structured Output" }), + ).toBeInTheDocument(); + }); + }); + it("invokes onClear when the close button is clicked", async () => { const user = userEvent.setup(); const onClear = vi.fn(); diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx index 08db06a18..33ca77617 100644 --- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx +++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx @@ -14,6 +14,7 @@ import type { } from "@modelcontextprotocol/client"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; import { ResourceLink } from "../ResourceLink/ResourceLink"; +import { StructuredOutputPanel } from "../StructuredOutputPanel/StructuredOutputPanel"; import { resultHasResourceLinks } from "./toolResultUtils"; export interface ToolResultPanelProps { @@ -217,6 +218,16 @@ export function ToolResultPanel({ // available height (and scrolls inside). Plain text/image results keep the // scroll-within-card body so a short result doesn't reserve empty height. const hasLinks = resultHasResourceLinks(result); + // A tool with an `outputSchema` returns its real payload in + // `structuredContent`, which the `content[]` blocks typically only summarize + // (#1908). Render it as its own section — including alongside an error or an + // empty `content` array, so it is never silently dropped. + const structuredNode = result.structuredContent ? ( + <StructuredOutputPanel + key="structured-output" + structuredContent={result.structuredContent} + /> + ) : null; const segmentNodes = segments.map((segment) => { if (segment.kind === "links") { @@ -254,22 +265,31 @@ export function ToolResultPanel({ </HeaderRow> {result.isError ? ( <ResultScroll> - <ErrorAlert> - {result.content - .filter((b) => b.type === "text") - .map((b) => b.text) - .join("\n")} - </ErrorAlert> + <ResultStack> + <ErrorAlert> + {result.content + .filter((b) => b.type === "text") + .map((b) => b.text) + .join("\n")} + </ErrorAlert> + {structuredNode} + </ResultStack> </ResultScroll> - ) : result.content.length === 0 ? ( + ) : result.content.length === 0 && !structuredNode ? ( <ResultScroll> <Text c="dimmed">No results yet</Text> </ResultScroll> ) : hasLinks ? ( - <FillStack>{segmentNodes}</FillStack> + <FillStack> + {segmentNodes} + {structuredNode} + </FillStack> ) : ( <ResultScroll> - <ResultStack>{segmentNodes}</ResultStack> + <ResultStack> + {segmentNodes} + {structuredNode} + </ResultStack> </ResultScroll> )} </PanelStack> diff --git a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx index de43f4887..c599ed477 100644 --- a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx +++ b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx @@ -33,6 +33,8 @@ export interface PromptsScreenProps { completionsSupported?: boolean; onUiChange: (next: PromptsUiState) => void; onRefreshList: () => void; + /** A failed list load, rendered above the sidebar list (#1953). */ + loadError?: Error | null; /** Pagination controls rendered in the sidebar (#1721). */ pagination: ListPaginationControlsProps; onGetPrompt: (name: string, args: Record<string, string>) => void; @@ -136,6 +138,7 @@ export function PromptsScreen({ completionsSupported, onUiChange, onRefreshList, + loadError, pagination, onGetPrompt, onCopyMessages, @@ -273,6 +276,7 @@ export function PromptsScreen({ searchText={search} listChanged={listChanged} onRefreshList={onRefreshList} + loadError={loadError} pagination={pagination} onSearchChange={(value) => onUiChange({ ...ui, search: value })} onSelectPrompt={handleSelectPrompt} diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx index bf07fb436..a94f91699 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx @@ -57,6 +57,8 @@ export interface ResourcesScreenProps { subscriptionsSupported?: boolean; onUiChange: (next: ResourcesUiState) => void; onRefreshList: () => void; + /** A failed list load, rendered above the sidebar list (#1953). */ + loadError?: Error | null; /** Pagination controls rendered in the sidebar (#1721). */ pagination: ListPaginationControlsProps; onReadResource: (uri: string) => void; @@ -170,6 +172,7 @@ export function ResourcesScreen({ subscriptionsSupported = true, onUiChange, onRefreshList, + loadError, pagination, onReadResource, onSubscribeResource, @@ -325,6 +328,7 @@ export function ResourcesScreen({ openSections={openSections} listChanged={listChanged} onRefreshList={onRefreshList} + loadError={loadError} pagination={pagination} onSearchChange={(value) => onUiChange({ ...ui, search: value })} onOpenSectionsChange={(value) => diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx index 51f55c9a4..f6dd4ed31 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx @@ -60,6 +60,8 @@ export interface ToolsScreenProps { modernTasks?: boolean; onUiChange: (next: ToolsUiState) => void; onRefreshList: () => void; + /** A failed list load, rendered above the sidebar list (#1953). */ + loadError?: Error | null; /** Pagination controls rendered in the sidebar (#1721). */ pagination: ListPaginationControlsProps; onCallTool: ( @@ -152,6 +154,7 @@ export function ToolsScreen({ modernTasks = false, onUiChange, onRefreshList, + loadError, pagination, onCallTool, onCancelCall, @@ -192,6 +195,7 @@ export function ToolsScreen({ searchText={search} listChanged={listChanged} onRefreshList={onRefreshList} + loadError={loadError} pagination={pagination} onSearchChange={(value) => onUiChange({ ...ui, search: value })} onSelectTool={handleSelectTool} diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 06d1098b9..6d162fbf7 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -435,6 +435,17 @@ export interface InspectorViewProps { toolsListChanged: boolean; promptsListChanged: boolean; resourcesListChanged: boolean; + + // Last list-load failure per screen, sourced from the same managed-state + // layer. Rendered above the sidebar list so a failed load (including the + // connect-time one) can't read as "this server has none" (#1953). + toolsLoadError?: Error | null; + promptsLoadError?: Error | null; + /** + * The Resources sidebar lists resources AND templates behind a single + * Refresh, so App passes whichever of the two loads failed. + */ + resourcesLoadError?: Error | null; logs: LogEntryData[]; tasks: Task[]; progressByTaskId?: Record<string, TaskProgress>; @@ -627,6 +638,9 @@ export function InspectorView({ toolsListChanged, promptsListChanged, resourcesListChanged, + toolsLoadError, + promptsLoadError, + resourcesLoadError, subscriptions, subscriptionStreamState, logs, @@ -1354,6 +1368,7 @@ export function InspectorView({ callState={toolCallState} ui={toolsUi} listChanged={toolsListChanged} + loadError={toolsLoadError} serverSupportsTaskToolCalls={serverSupportsTaskToolCalls} modernTasks={ protocolEra === "modern" && @@ -1393,6 +1408,7 @@ export function InspectorView({ getPromptState={getPromptState} ui={promptsUi} listChanged={promptsListChanged} + loadError={promptsLoadError} completionsSupported={completionsSupported} onUiChange={onPromptsUiChange} onRefreshList={onRefreshPrompts} @@ -1412,6 +1428,7 @@ export function InspectorView({ readState={readResourceState} ui={resourcesUi} listChanged={resourcesListChanged} + loadError={resourcesLoadError} completionsSupported={completionsSupported} subscriptionsSupported={subscriptionsSupported} onUiChange={onResourcesUiChange} diff --git a/clients/web/src/test/PreviewAnnotations.stories.tsx b/clients/web/src/test/PreviewAnnotations.stories.tsx new file mode 100644 index 000000000..bca4fb481 --- /dev/null +++ b/clients/web/src/test/PreviewAnnotations.stories.tsx @@ -0,0 +1,93 @@ +import { Button } from "@mantine/core"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { theme } from "../theme/theme"; + +// Guard for #1898. +// +// `.storybook/vitest.setup.ts` used to call `setProjectAnnotations([...])` to +// hand the preview annotations to the Storybook vitest project. Since Storybook +// 10.3 `@storybook/addon-vitest` applies them automatically — and *skips* doing +// so when it finds such a setup file — so the file was removed and the +// `setupFiles` entry dropped from the `storybook` project in `vite.config.ts`. +// +// A green suite is not evidence that the automatic path works. Two things the +// setup file used to provision are silently losable: +// +// • `./preview`'s decorator, which wraps every story in `MantineProvider` +// with the project theme, and its `import "../src/App.css"`, which defines +// the `--inspector-*` tokens. Without them stories render unthemed and +// unstyled — and would very likely still pass their play functions. +// • `@storybook/addon-a11y/preview`, which runs axe after each play function. +// Without it the `a11y: { test: "error" }` parameter is inert and every +// story passes its accessibility check vacuously. +// +// The story below closes the first gap: it asserts the Mantine theme variables +// and the `App.css` tokens are actually present in the rendered document, so an +// unthemed render fails loudly rather than passing. It also asserts the preview +// `parameters` reached the story, which is the same channel the a11y parameter +// arrives on. The axe *runner* itself can't be introspected from a play +// function; it was verified out-of-band by temporarily giving a story a real +// violation and confirming the suite went red (see the PR for #1898). + +// Mantine's own default when a theme pins no `primaryShade` — the value it +// falls back to when deriving `--mantine-primary-color-filled`. +const MANTINE_DEFAULT_LIGHT_PRIMARY_SHADE = 6; + +// The shade Mantine derives `--mantine-primary-color-filled` from. Everything +// here is read from the theme rather than restated, so re-pinning +// `primaryShade` or repainting the palette moves the expectation with it — a +// guard that had to be edited alongside a legitimate theme change would just +// train people to edit it, which is the opposite of what it's for. +// +// The light branch is the one that matters: `./preview` renders every story +// with `defaultColorScheme="light"` and an `initialGlobals.colorScheme` of +// `"light"`. `primaryShade` is `number | { light, dark }` in Mantine's types, +// so both forms are handled. +function lightPrimaryShade(): number { + const shade = theme.primaryShade; + if (typeof shade === "number") return shade; + return shade?.light ?? MANTINE_DEFAULT_LIGHT_PRIMARY_SHADE; +} + +function expectedPrimaryColor(): string { + const palette = theme.colors?.[theme.primaryColor ?? ""]; + const color = palette?.[lightPrimaryShade()]; + if (!color) throw new Error("theme is missing its primary color palette"); + return color; +} + +const meta: Meta<typeof Button> = { + title: "Meta/Preview Annotations", + component: Button, + args: { children: "Themed" }, +}; + +export default meta; +type Story = StoryObj<typeof Button>; + +export const ProjectAnnotationsApplied: Story = { + play: async ({ canvasElement, parameters }) => { + const canvas = within(canvasElement); + expect(canvas.getByRole("button", { name: "Themed" })).toBeInTheDocument(); + + const root = getComputedStyle(document.documentElement); + + // `MantineProvider` (the `./preview` decorator) injects the theme's CSS + // variables onto `:root`. No provider, no variables. + expect(root.getPropertyValue("--mantine-primary-color-filled").trim()).toBe( + expectedPrimaryColor(), + ); + + // `--inspector-brand-primary` is defined in `App.css`, which only reaches + // the story through `./preview`'s stylesheet import — and it resolves + // *through* the Mantine variable above, so this covers both layers. + expect(root.getPropertyValue("--inspector-brand-primary").trim()).toBe( + expectedPrimaryColor(), + ); + + // The preview `parameters` merged into the story context — the same channel + // that carries `a11y: { test: "error" }` to the a11y addon. + expect(parameters.a11y).toMatchObject({ test: "error" }); + }, +}; diff --git a/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts b/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts index 64a1328d5..c04788510 100644 --- a/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts +++ b/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts @@ -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(); + }); }); diff --git a/clients/web/src/test/core/mcp/serverList.test.ts b/clients/web/src/test/core/mcp/serverList.test.ts index d6118fbd4..c5eee5232 100644 --- a/clients/web/src/test/core/mcp/serverList.test.ts +++ b/clients/web/src/test/core/mcp/serverList.test.ts @@ -1076,6 +1076,36 @@ describe("expectedSecretFields", () => { }), ).toEqual([SECRET_FIELD_OAUTH_CLIENT_SECRET]); }); + + it("keeps a secret-only OAuth config reachable across the disk round trip", () => { + // The OAuth slot is unconditional, and that is load-bearing rather + // than merely defensive — skipping the keychain read for servers with + // "no OAuth config" is a tempting optimization that would silently + // stop rehydrating exactly this shape. + // + // `extractSecretsFromStored` deletes the `oauth` block outright when + // `clientSecret` was its only property, so the on-disk entry carries + // NO marker that a secret exists — the keychain is the only record. + // The unconditional slot is what finds it again. + const { stripped, secrets } = extractSecretsFromStored({ + type: "streamable-http", + url: "https://x.test", + oauth: { clientSecret: "shh" }, + }); + expect(stripped).not.toHaveProperty("oauth"); + + // What a GET does: enumerate fields from the *stripped* on-disk shape, + // read those from the keychain, merge back. + const fields = expectedSecretFields(stripped); + expect(fields).toContain(SECRET_FIELD_OAUTH_CLIENT_SECRET); + + const fromKeychain = Object.fromEntries( + fields.filter((f) => f in secrets).map((f) => [f, secrets[f]!]), + ); + expect(mergeSecretsIntoStored(stripped, fromKeychain).oauth).toEqual({ + clientSecret: "shh", + }); + }); }); describe("enterpriseManaged oauth settings", () => { diff --git a/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts b/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts index 17c1113f1..939af2efd 100644 --- a/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; import type { Prompt } from "@modelcontextprotocol/client"; import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsState"; @@ -285,6 +286,22 @@ describe("ManagedPromptsState", () => { }); }); + // The base class owns the error plumbing (covered in managedToolsState); this + // pins THIS list's method string, which is what attributes a failure to the + // right Protocol entry (#1953). + it("records a failed load and attributes it to prompts/list", async () => { + const boom = new SdkError(SdkErrorCode.InvalidResult, "nope"); + client.setStatus("connected"); + client.listAllPrompts.mockRejectedValueOnce(boom); + + await expect(state.refresh()).rejects.toThrow(boom); + expect(state.getError()).toBe(boom); + expect(client.markResponseRejected).toHaveBeenCalledWith( + "prompts/list", + "nope", + ); + }); + it("destroy is idempotent", () => { state.destroy(); expect(() => state.destroy()).not.toThrow(); diff --git a/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts b/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts index c48507604..be88cd5f2 100644 --- a/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client"; import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState"; @@ -266,6 +267,22 @@ describe("ManagedResourceTemplatesState", () => { expect(state.getResourceTemplates()).toEqual([]); }); + // The base class owns the error plumbing (covered in managedToolsState); this + // pins THIS list's method string, which is what attributes a failure to the + // right Protocol entry (#1953). + it("records a failed load and attributes it to resources/templates/list", async () => { + const boom = new SdkError(SdkErrorCode.InvalidResult, "nope"); + client.setStatus("connected"); + client.listAllResourceTemplates.mockRejectedValueOnce(boom); + + await expect(state.refresh()).rejects.toThrow(boom); + expect(state.getError()).toBe(boom); + expect(client.markResponseRejected).toHaveBeenCalledWith( + "resources/templates/list", + "nope", + ); + }); + it("destroy is idempotent", () => { state.destroy(); expect(() => state.destroy()).not.toThrow(); diff --git a/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts b/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts index 3e82467c3..7944f3a4d 100644 --- a/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; import type { Resource } from "@modelcontextprotocol/client"; import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState"; @@ -292,6 +293,22 @@ describe("ManagedResourcesState", () => { }); }); + // The base class owns the error plumbing (covered in managedToolsState); this + // pins THIS list's method string, which is what attributes a failure to the + // right Protocol entry (#1953). + it("records a failed load and attributes it to resources/list", async () => { + const boom = new SdkError(SdkErrorCode.InvalidResult, "nope"); + client.setStatus("connected"); + client.listAllResources.mockRejectedValueOnce(boom); + + await expect(state.refresh()).rejects.toThrow(boom); + expect(state.getError()).toBe(boom); + expect(client.markResponseRejected).toHaveBeenCalledWith( + "resources/list", + "nope", + ); + }); + it("destroy is idempotent", () => { state.destroy(); expect(() => state.destroy()).not.toThrow(); diff --git a/clients/web/src/test/core/mcp/state/managedToolsState.test.ts b/clients/web/src/test/core/mcp/state/managedToolsState.test.ts index 449c1b24c..8455dd74b 100644 --- a/clients/web/src/test/core/mcp/state/managedToolsState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedToolsState.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; import type { Tool } from "@modelcontextprotocol/client"; import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState"; @@ -412,6 +413,172 @@ describe("ManagedToolsState", () => { }); }); + // A failed load used to vanish: the connect-time refresh is fired with no + // caller to await it, so its rejection became an unhandled rejection and the + // panel just rendered an empty list — indistinguishable from a server with no + // tools (#1953). + describe("load errors", () => { + const boom = new Error("Invalid result for tools/list: ttlMs required"); + + function waitForError(state: ManagedToolsState): Promise<Error | null> { + return waitForChangeEvent(state, "errorChange"); + } + + it("starts with no error", () => { + expect(state.getError()).toBeNull(); + }); + + it("records a failed refresh as state AND re-throws", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce(boom); + + // The rejection still propagates: App's auth-recovery wrapper keys off it + // to detect a 401 and start a re-authorization. + await expect(state.refresh()).rejects.toThrow(boom); + expect(state.getError()).toBe(boom); + }); + + it("dispatches errorChange with the error", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce(boom); + const changed = waitForError(state); + await expect(state.refresh()).rejects.toThrow(boom); + expect(await changed).toBe(boom); + }); + + it("wraps a non-Error rejection", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce("just a string"); + await expect(state.refresh()).rejects.toBe("just a string"); + expect(state.getError()).toBeInstanceOf(Error); + expect(state.getError()?.message).toBe("just a string"); + }); + + // Only a decode rejection may be attributed to a Protocol entry. The id is + // recovered as "the last response for this method", which is the failing + // exchange ONLY when a response actually arrived and was refused — see + // isClientDecodeRejection. + describe("Protocol-entry attribution", () => { + const decodeRejection = new SdkError( + SdkErrorCode.InvalidResult, + "Invalid result for tools/list: ttlMs required", + ); + + it("attributes a decode rejection to its Protocol entry", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce(decodeRejection); + await expect(state.refresh()).rejects.toThrow(decodeRejection); + expect(client.markResponseRejected).toHaveBeenCalledWith( + "tools/list", + decodeRejection.message, + ); + }); + + it("attributes an unsupported resultType too", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce( + new SdkError(SdkErrorCode.UnsupportedResultType, "unknown type"), + ); + await expect(state.refresh()).rejects.toThrow(); + expect(client.markResponseRejected).toHaveBeenCalledWith( + "tools/list", + "unknown type", + ); + }); + + // The regression this guard exists for: no response frame arrived, so the + // last-answered id still points at an EARLIER successful call. Marking it + // would stamp "Rejected by the Inspector" onto an exchange that worked. + it("does NOT attribute a transport failure", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce( + new SdkError(SdkErrorCode.ConnectionClosed, "Connection closed"), + ); + await expect(state.refresh()).rejects.toThrow(); + expect(client.markResponseRejected).not.toHaveBeenCalled(); + }); + + it("does NOT attribute a request timeout", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce( + new SdkError(SdkErrorCode.RequestTimeout, "Request timed out"), + ); + await expect(state.refresh()).rejects.toThrow(); + expect(client.markResponseRejected).not.toHaveBeenCalled(); + }); + + // A real response, so the id would be right — but the failure is the + // server's, and its entry already renders as an error from the error + // frame. Blaming the Inspector would misattribute the cause. + it("does NOT attribute a plain (non-SDK) error", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce(boom); + await expect(state.refresh()).rejects.toThrow(boom); + expect(client.markResponseRejected).not.toHaveBeenCalled(); + }); + + it("still records every failure as state, attributed or not", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce(boom); + await expect(state.refresh()).rejects.toThrow(boom); + expect(state.getError()).toBe(boom); + expect(client.markResponseRejected).not.toHaveBeenCalled(); + }); + }); + + it("clears the error once a refresh succeeds", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce(boom); + await expect(state.refresh()).rejects.toThrow(boom); + + const cleared = waitForError(state); + client.queueToolPages({ tools: [tool("a")] }); + await state.refresh(); + expect(await cleared).toBeNull(); + expect(state.getError()).toBeNull(); + }); + + it("keeps the connect-time failure in state instead of rejecting unobserved", async () => { + client.listAllTools.mockRejectedValueOnce(boom); + const changed = waitForError(state); + await client.connect(); + expect(await changed).toBe(boom); + }); + + it("keeps the auto-refresh failure in state instead of rejecting unobserved", async () => { + client.setServerSettings(AUTO_REFRESH_SETTINGS); + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce(boom); + const changed = waitForError(state); + client.dispatchTypedEvent("toolsListChanged"); + expect(await changed).toBe(boom); + }); + + it("clears the error on disconnect so it can't outlive its session", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce(boom); + await expect(state.refresh()).rejects.toThrow(boom); + + const cleared = waitForError(state); + client.setStatus("disconnected"); + expect(await cleared).toBeNull(); + expect(state.getError()).toBeNull(); + }); + + it("does not re-dispatch when the same error is recorded twice", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValue(boom); + await expect(state.refresh()).rejects.toThrow(boom); + + let fired = 0; + state.addEventListener("errorChange", () => { + fired++; + }); + await expect(state.refresh()).rejects.toThrow(boom); + expect(fired).toBe(0); + }); + }); + it("destroy is idempotent", () => { state.destroy(); expect(() => state.destroy()).not.toThrow(); diff --git a/clients/web/src/test/core/mcp/state/messageLogState.test.ts b/clients/web/src/test/core/mcp/state/messageLogState.test.ts index b1088ddfd..f6c11db36 100644 --- a/clients/web/src/test/core/mcp/state/messageLogState.test.ts +++ b/clients/web/src/test/core/mcp/state/messageLogState.test.ts @@ -234,4 +234,69 @@ describe("MessageLogState", () => { state.destroy(); expect(() => state.destroy()).not.toThrow(); }); + + // A response the SERVER answered successfully but the CLIENT then refused + // (the SDK codec rejecting the result). The frame is a valid JSON-RPC + // result, so without the annotation the entry renders as a clean success + // (#1953). + describe("responseRejected", () => { + const REASON = "Invalid result for tools/list: ttlMs required"; + + it("annotates the request entry the response was folded into", () => { + client.dispatchTypedEvent("message", requestEntry(1)); + client.dispatchTypedEvent("message", responseEntry(1)); + + const seen: MessageEntry[] = []; + state.addEventListener("message", (e) => seen.push(e.detail)); + client.dispatchTypedEvent("responseRejected", { id: 1, reason: REASON }); + + expect(state.getMessages()[0]?.clientError).toBe(REASON); + expect(seen).toHaveLength(1); + }); + + it("annotates a standalone response entry with no matching request", () => { + client.dispatchTypedEvent("message", responseEntry(7)); + client.dispatchTypedEvent("responseRejected", { id: 7, reason: REASON }); + + expect(state.getMessages()[0]?.clientError).toBe(REASON); + }); + + it("skips a request still awaiting its response", () => { + // Same JSON-RPC id reused after the first exchange completed: the + // pending one isn't what was rejected. + client.dispatchTypedEvent("message", requestEntry(1)); + client.dispatchTypedEvent("message", responseEntry(1)); + client.dispatchTypedEvent("message", requestEntry(1)); + + client.dispatchTypedEvent("responseRejected", { id: 1, reason: REASON }); + + const [completed, pending] = state.getMessages(); + expect(pending?.clientError).toBeUndefined(); + expect(completed?.clientError).toBe(REASON); + }); + + it("ignores an id that matches no entry", () => { + client.dispatchTypedEvent("message", requestEntry(1)); + client.dispatchTypedEvent("message", responseEntry(1)); + + let fired = 0; + state.addEventListener("messagesChange", () => { + fired++; + }); + client.dispatchTypedEvent("responseRejected", { id: 99, reason: REASON }); + + expect(fired).toBe(0); + expect(state.getMessages()[0]?.clientError).toBeUndefined(); + }); + + it("never annotates a notification", () => { + client.dispatchTypedEvent("message", notificationEntry()); + client.dispatchTypedEvent("responseRejected", { + id: 1, + reason: REASON, + }); + + expect(state.getMessages()[0]?.clientError).toBeUndefined(); + }); + }); }); diff --git a/clients/web/src/test/core/react/useManagedListError.test.tsx b/clients/web/src/test/core/react/useManagedListError.test.tsx new file mode 100644 index 000000000..1eac0afde --- /dev/null +++ b/clients/web/src/test/core/react/useManagedListError.test.tsx @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient"; +import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState"; +import { useManagedListError } from "@inspector/core/react/useManagedListError"; + +// The shared subscription behind the four `useManaged*` hooks' `error` field +// (#1953). Exercised through ManagedToolsState — any managed list would do, +// since the error lives entirely in the shared base. +describe("useManagedListError", () => { + let client: FakeInspectorClient; + let state: ManagedToolsState; + const boom = new Error("Invalid result for tools/list: ttlMs required"); + + beforeEach(() => { + client = new FakeInspectorClient({ + status: "connected", + capabilities: { tools: {} }, + }); + state = new ManagedToolsState(client, 0); + }); + + it("returns null when there is no state", () => { + const { result } = renderHook(() => useManagedListError(null)); + expect(result.current).toBeNull(); + }); + + it("returns null before any load fails", () => { + const { result } = renderHook(() => useManagedListError(state)); + expect(result.current).toBeNull(); + }); + + it("seeds from an error the state already holds", async () => { + client.listAllTools.mockRejectedValueOnce(boom); + await expect(state.refresh()).rejects.toThrow(boom); + + const { result } = renderHook(() => useManagedListError(state)); + expect(result.current).toBe(boom); + }); + + it("updates when the state dispatches errorChange", async () => { + const { result } = renderHook(() => useManagedListError(state)); + + client.listAllTools.mockRejectedValueOnce(boom); + await act(async () => { + await expect(state.refresh()).rejects.toThrow(boom); + }); + expect(result.current).toBe(boom); + }); + + it("clears when a later load succeeds", async () => { + client.listAllTools.mockRejectedValueOnce(boom); + await expect(state.refresh()).rejects.toThrow(boom); + const { result } = renderHook(() => useManagedListError(state)); + expect(result.current).toBe(boom); + + await act(async () => { + await state.refresh(); + }); + expect(result.current).toBeNull(); + }); + + it("resets to null when the state becomes null", async () => { + client.listAllTools.mockRejectedValueOnce(boom); + await expect(state.refresh()).rejects.toThrow(boom); + + const { result, rerender } = renderHook( + ({ s }: { s: ManagedToolsState | null }) => useManagedListError(s), + { initialProps: { s: state as ManagedToolsState | null } }, + ); + expect(result.current).toBe(boom); + + rerender({ s: null }); + expect(result.current).toBeNull(); + }); + + it("unsubscribes on unmount", async () => { + const { unmount } = renderHook(() => useManagedListError(state)); + unmount(); + + client.listAllTools.mockRejectedValueOnce(boom); + // No act() wrapper: a listener still attached would warn about an update + // outside act, and the assertion below would be the only other signal. + await expect(state.refresh()).rejects.toThrow(boom); + expect(state.getError()).toBe(boom); + }); + + // The useState+useEffect subscribe pattern would render one frame carrying + // the PREVIOUS store's error here, before the effect re-synced. With + // useSyncExternalStore the snapshot is read during render, so the swap lands + // in the same frame — asserted immediately after rerender, with no waitFor + // and no act() flush, which is what makes it a regression test rather than a + // restatement of eventual consistency. + it("reflects a store swap in the same frame", async () => { + client.listAllTools.mockRejectedValueOnce(boom); + await expect(state.refresh()).rejects.toThrow(boom); + + const other = new ManagedToolsState(client, 0); + + const { result, rerender } = renderHook( + ({ s }: { s: ManagedToolsState }) => useManagedListError(s), + { initialProps: { s: state } }, + ); + expect(result.current).toBe(boom); + + rerender({ s: other }); + expect(result.current).toBeNull(); + + rerender({ s: state }); + expect(result.current).toBe(boom); + }); +}); diff --git a/clients/web/src/test/core/react/useServers.test.tsx b/clients/web/src/test/core/react/useServers.test.tsx index 0ee49df7d..bb95402d9 100644 --- a/clients/web/src/test/core/react/useServers.test.tsx +++ b/clients/web/src/test/core/react/useServers.test.tsx @@ -969,6 +969,74 @@ describe("useServers", () => { ); }); + it("ignores the backend's inert priming comment frame (#1858)", async () => { + // The backend opens every SSE stream with a `:` comment so a streaming + // fetch() resolves on Firefox at all. That frame carries no event/data + // field and must NOT be read as a change — otherwise every connection + // would fire a spurious background re-fetch on open. + writeFileSync( + h.configPath, + JSON.stringify({ + mcpServers: { seed: { type: "stdio", command: "s" } }, + }), + ); + + // Count list GETs rather than watching the rendered list: a spurious + // refresh fires the instant the priming frame is read, so it would race + // ahead of any later disk mutation and land the same data — invisible in + // the output but a real extra round-trip on every connection. + let listGets = 0; + let reads = 0; + let releaseSecondRead: (() => void) | undefined; + const secondRead = new Promise<void>((r) => { + releaseSecondRead = r; + }); + const encoder = new TextEncoder(); + // Must be referentially stable: the hook's SSE effect keys off `fetchFn`, + // so an inline arrow would re-subscribe (and re-refresh) every render and + // inflate the very count this test asserts on. + const fetchFn: typeof fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith("/api/servers/events")) { + const body = { + getReader: () => ({ + read: async () => { + reads += 1; + // Priming comment only — no `event:` / `data:` line. + if (reads === 1) { + return { done: false, value: encoder.encode(":\n\n") }; + } + // Hold the stream open so the loop can't end and let a + // teardown-time settle hide a queued refresh. + await secondRead; + return { done: true, value: undefined }; + }, + }), + }; + return { ok: true, body } as unknown as Response; + } + if (url.endsWith("/api/servers")) listGets += 1; + return h.fetchFn(url, init); + }; + const { result } = renderHook(() => + useServers({ baseUrl: "http://test.local", fetchFn }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.servers.map((s) => s.id)).toEqual(["seed"]); + + // The priming frame has been consumed (the loop is parked on read #2). + await waitFor(() => expect(reads).toBeGreaterThanOrEqual(2)); + await new Promise((r) => setTimeout(r, 100)); + + // Exactly one GET: the hook's mount refresh. The comment frame added none. + expect(listGets).toBe(1); + + await act(async () => { + releaseSecondRead?.(); + await Promise.resolve(); + }); + }); + it("falls back to globalThis.fetch when no fetchFn is provided", async () => { // No fetchFn → the `doFetch = fetchFn ?? globalThis.fetch` default branch. const globalFetch = vi diff --git a/clients/web/src/test/integration/auth/node/secret-store.test.ts b/clients/web/src/test/integration/auth/node/secret-store.test.ts index f118081c3..2d66858e3 100644 --- a/clients/web/src/test/integration/auth/node/secret-store.test.ts +++ b/clients/web/src/test/integration/auth/node/secret-store.test.ts @@ -7,8 +7,16 @@ * so the suite stubs the native side and asserts the tolerance contract * (`get` returns null on failure, destructive ops no-op, `set` is the * one operation that hard-fails with `KeychainUnavailableError`). + * + * The contract has four entry points for "unavailable" and the suite + * covers all four: the operation throwing, `AsyncEntry`'s constructor + * throwing (#1848), the package failing to load at all (#1905), and the + * package loading but exposing the wrong shape. The last two can't use + * the shared stub — one needs the *import* to reject, the other needs it + * to resolve to a namespace the stub can't express — so each lives in + * its own describe built on `vi.resetModules()`. */ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; // The mock must be hoisted above the `await import` of secret-store // inside the `KeyringSecretStore` describe block. Use `vi.hoisted` so @@ -24,6 +32,12 @@ const keyringMocks = vi.hoisted(() => { deleteThrows: false, findThrows: false, deleteThrowsNoEntry: false, + // `AsyncEntry::new` itself does the platform-store setup and throws + // when no backend is reachable (a container with no D-Bus session, + // Linux without libsecret). Modelling it is what catches #1848: a + // stub that can only fail per-method leaves the construction path + // untested, so an escaping constructor error looks green here. + constructorThrows: false, }; const credentials = (): Array<{ account: string; password: string }> => { const out: Array<{ account: string; password: string }> = []; @@ -35,6 +49,11 @@ const keyringMocks = vi.hoisted(() => { class AsyncEntry { private readonly key: string; constructor(_service: string, username: string) { + if (failures.constructorThrows) { + throw new Error( + "Couldn't access platform storage: PermissionDenied (constructor)", + ); + } this.key = username; } async getPassword(): Promise<string | undefined> { @@ -70,6 +89,7 @@ import { InMemorySecretStore, KeyringSecretStore, KeychainUnavailableError, + KeyringModuleShapeError, SECRET_FIELD_OAUTH_CLIENT_SECRET, envSecretField, parseAccount, @@ -215,6 +235,7 @@ describe("KeyringSecretStore (mocked native bindings)", () => { keyringMocks.failures.deleteThrows = false; keyringMocks.failures.findThrows = false; keyringMocks.failures.deleteThrowsNoEntry = false; + keyringMocks.failures.constructorThrows = false; store = new KeyringSecretStore(); }); @@ -318,6 +339,59 @@ describe("KeyringSecretStore (mocked native bindings)", () => { expect(err.message).toMatch(/libsecret/); }); + // The two hint branches are asserted by direct construction rather than + // through the store: the unloadable-package path can only be reached via + // a throwing `vi.doMock` factory, and vitest substitutes its own "error + // when mocking a module" message for whatever that factory throws — so + // the real loader text never reaches the constructor from there. These + // two cases pin the wording against the message the napi-rs loader + // actually produces. + it("KeychainUnavailableError steers a missing native binding to a reinstall", () => { + const err = new KeychainUnavailableError( + new Error( + "Cannot find native binding. npm has a bug related to optional dependencies…", + ), + ); + expect(err.message).toMatch(/reinstall the Inspector/); + expect(err.message).toMatch(/npx cache/); + // The Linux keyring-daemon advice is irrelevant to this cause. + expect(err.message).not.toMatch(/libsecret/); + expect(err.message).toMatch(/Cannot find native binding/); + }); + + it("KeychainUnavailableError points a wrong-shape module at a reinstall, not at libsecret", () => { + // A packaging/version mismatch is not a missing keyring daemon, so + // the libsecret line would send the user somewhere that cannot help. + const err = new KeychainUnavailableError( + new KeyringModuleShapeError("did not expose AsyncEntry"), + ); + expect(err.message).toMatch(/does not expose the API this build expects/); + expect(err.message).not.toMatch(/libsecret/); + }); + + it("KeychainUnavailableError gives the same hint however the shape check failed", () => { + // The shape check fails two ways — members absent, or reading them + // throws. Both are the same underlying problem, so both must earn the + // packaging hint; only one of them carrying it was the original bug. + const err = new KeychainUnavailableError( + new KeyringModuleShapeError("its exports could not be read: boom", { + cause: new Error("boom"), + }), + ); + expect(err.message).toMatch(/does not expose the API this build expects/); + expect(err.message).not.toMatch(/libsecret/); + // The original failure is still legible in the message. + expect(err.message).toMatch(/boom/); + }); + + it("KeychainUnavailableError keeps the libsecret advice for other causes", () => { + const err = new KeychainUnavailableError( + new Error("failed to unlock the default collection"), + ); + expect(err.message).toMatch(/libsecret/); + expect(err.message).not.toMatch(/reinstall the Inspector/); + }); + it("KeychainUnavailableError carries the underlying error message", async () => { keyringMocks.failures.setThrows = true; try { @@ -329,4 +403,299 @@ describe("KeyringSecretStore (mocked native bindings)", () => { expect((err as Error).message).toMatch(/libsecret/); } }); + + describe("keychain unreachable at AsyncEntry construction (#1848)", () => { + // `AsyncEntry::new` — not just its methods — throws when no platform + // store is reachable. The degradation contract must hold identically + // for that failure mode; constructing outside the `try` let the raw + // keyring error escape and 500 `GET /api/servers` before any secret + // was touched. + beforeEach(() => { + keyringMocks.failures.constructorThrows = true; + }); + + it("get returns null", async () => { + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( + null, + ); + }); + + it("set throws KeychainUnavailableError, not the raw keyring error", async () => { + // The typed error is what the routes translate to a 503 and what + // `migratePlaintextSecrets` matches on to skip migration. + await expect( + store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v"), + ).rejects.toBeInstanceOf(KeychainUnavailableError); + await expect( + store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v"), + ).rejects.toThrow(/Couldn't access platform storage/); + }); + + it("delete silently no-ops", async () => { + await expect( + store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET), + ).resolves.toBeUndefined(); + }); + + it("deleteAllForServer no-ops even when the credential sweep finds entries", async () => { + // findCredentialsAsync can succeed while per-entry construction + // fails; the sweep must still resolve rather than escape. + keyringMocks.failures.constructorThrows = false; + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a"); + keyringMocks.failures.constructorThrows = true; + + await expect(store.deleteAllForServer("alpha")).resolves.toBeUndefined(); + }); + }); +}); + +describe("@napi-rs/keyring unloadable on this platform (#1905)", () => { + // `@napi-rs/keyring` ships one prebuilt binary per platform triple and + // throws on import where it has none — Android / Termux is the reported + // case. A static top-level import made that a startup crash: the module + // never evaluated, so none of the tolerance handling could run. + // + // The shared stub above can't express this: it models a keyring that + // loads. Here the *import itself* rejects, which needs a fresh module + // registry — hence `vi.resetModules()` + `vi.doMock` and a re-import + // rather than a flag. The re-imported module is a distinct instance, so + // its `KeychainUnavailableError` is a distinct class identity too; the + // assertions below use the freshly imported one. + // The real-world message on Termux. Documentary here — see the cause + // assertion below for why vitest doesn't let it through verbatim. + const LOAD_ERROR = "Cannot find module '@napi-rs/keyring-android-arm64'"; + + /** Fresh secret-store module whose `@napi-rs/keyring` import rejects. */ + const importWithUnloadableKeyring = async ( + onLoadAttempt: () => void = () => {}, + ) => { + vi.resetModules(); + vi.doMock("@napi-rs/keyring", () => { + onLoadAttempt(); + throw new Error(LOAD_ERROR); + }); + return await import("@inspector/core/auth/node/secret-store.js"); + }; + + afterEach(() => { + vi.doUnmock("@napi-rs/keyring"); + vi.resetModules(); + }); + + it("the module still evaluates — importing it must not throw", async () => { + // The regression itself: with a static top-level import this rejects, + // and the Inspector exits before reaching any fallback. + await expect(importWithUnloadableKeyring()).resolves.toHaveProperty( + "KeyringSecretStore", + ); + }); + + it("get returns null", async () => { + const mod = await importWithUnloadableKeyring(); + const store = new mod.KeyringSecretStore(); + expect(await store.get("alpha", "oauth-client-secret")).toBe(null); + }); + + it("set throws KeychainUnavailableError carrying the load failure as its cause", async () => { + const mod = await importWithUnloadableKeyring(); + const store = new mod.KeyringSecretStore(); + await expect( + store.set("alpha", "oauth-client-secret", "v"), + ).rejects.toBeInstanceOf(mod.KeychainUnavailableError); + // Asserted by shape, not by the literal text: vitest substitutes its + // own "error when mocking a module" message for a throwing `doMock` + // factory, so `LOAD_ERROR` never reaches the store here. What matters + // — and what is testable — is that the load failure is appended as + // the cause rather than swallowed. In production the real + // "Cannot find native binding" text lands in that slot. + await expect( + store.set("alpha", "oauth-client-secret", "v"), + ).rejects.toThrow(/Underlying error: .+/); + }); + + it("set does not double-wrap the typed error", async () => { + // The load failure is already a `KeychainUnavailableError` by the time + // the catch sees it; re-wrapping would bury the cause a level deeper. + const mod = await importWithUnloadableKeyring(); + const store = new mod.KeyringSecretStore(); + try { + await store.set("alpha", "oauth-client-secret", "v"); + throw new Error("expected throw"); + } catch (err) { + expect(err).toBeInstanceOf(mod.KeychainUnavailableError); + // One "OS keychain is not available" prefix, not two nested. + expect( + (err as Error).message.match(/OS keychain is not available/g), + ).toHaveLength(1); + } + }); + + it("delete and deleteAllForServer silently no-op", async () => { + const mod = await importWithUnloadableKeyring(); + const store = new mod.KeyringSecretStore(); + await expect( + store.delete("alpha", "oauth-client-secret"), + ).resolves.toBeUndefined(); + await expect(store.deleteAllForServer("alpha")).resolves.toBeUndefined(); + }); + + it("attempts the load once and caches the failure", async () => { + // Without the cache every secret operation re-attempts (and re-throws) + // the resolution — `expectedSecretFields` means that is once per + // server per `GET /api/servers`. + const onLoadAttempt = vi.fn(); + const mod = await importWithUnloadableKeyring(onLoadAttempt); + const store = new mod.KeyringSecretStore(); + + await store.get("alpha", "oauth-client-secret"); + await store.get("beta", "oauth-client-secret"); + await store.delete("alpha", "oauth-client-secret"); + + expect(onLoadAttempt).toHaveBeenCalledTimes(1); + }); +}); + +describe("@napi-rs/keyring loads but exposes the wrong shape", () => { + // The package is CJS, so `AsyncEntry` / `findCredentialsAsync` reach us + // through named-export interop. That holds today — verified against the + // real package — but if it ever stopped (a default-only export upstream, + // a bundler changing interop, a platform where named-export detection + // fails), the members would be `undefined` and `new undefined(...)` would + // throw a TypeError *inside* the try that implements degradation. + // + // What the shape check fixes is the *diagnosis*, not the data loss: `get` + // returns null with or without it (read-tolerance is its contract), so + // the empty secret list looks the same either way. The difference is that + // `set` names the shape problem at the load boundary instead of reporting + // "keyring.mod.AsyncEntry is not a constructor". Only the cause-message + // test below actually fails without the guard — the others pin the + // surrounding contract and would pass either way. + const importWithKeyringShape = async (shape: Record<string, unknown>) => { + vi.resetModules(); + vi.doMock("@napi-rs/keyring", () => shape); + return await import("@inspector/core/auth/node/secret-store.js"); + }; + + afterEach(() => { + vi.doUnmock("@napi-rs/keyring"); + vi.resetModules(); + }); + + // The members are declared but not callable, rather than absent. That is + // the shape a default-only export would leave behind, and it keeps these + // cases on the `typeof !== "function"` branch deterministically: vitest's + // module mock *throws* on reading a key the factory never returned, which + // is a different path (covered by its own case at the end). + const ENTRY_NOT_A_FUNCTION = { + AsyncEntry: undefined, + findCredentialsAsync: async () => [], + }; + + it("hard-fails set for a namespace without a callable AsyncEntry (get still returns null by contract)", async () => { + const mod = await importWithKeyringShape(ENTRY_NOT_A_FUNCTION); + const store = new mod.KeyringSecretStore(); + + // `get` returning null is the read-tolerance contract, not something + // the shape check changes — it reads the same as "no secret stored", + // which is exactly why `set` has to be the operation that hard-fails. + expect(await store.get("alpha", "oauth-client-secret")).toBe(null); + await expect( + store.set("alpha", "oauth-client-secret", "v"), + ).rejects.toBeInstanceOf(mod.KeychainUnavailableError); + }); + + it("names the shape problem as the cause, not a bare unavailability", async () => { + const mod = await importWithKeyringShape(ENTRY_NOT_A_FUNCTION); + const store = new mod.KeyringSecretStore(); + try { + await store.set("alpha", "oauth-client-secret", "v"); + throw new Error("expected throw"); + } catch (err) { + expect((err as Error).message).toMatch( + /did not expose AsyncEntry \/ findCredentialsAsync/, + ); + // Not double-wrapped: the load failure is already typed by the time + // `set`'s catch sees it. + expect( + (err as Error).message.match(/OS keychain is not available/g), + ).toHaveLength(1); + } + }); + + it("treats a namespace without a callable findCredentialsAsync as unavailable too", async () => { + // `deleteAllForServer` is the only caller of it, so a shape check on + // `AsyncEntry` alone would let this one through to a TypeError. + class StubEntry { + async getPassword(): Promise<string | undefined> { + return undefined; + } + } + const mod = await importWithKeyringShape({ + AsyncEntry: StubEntry, + findCredentialsAsync: undefined, + }); + const store = new mod.KeyringSecretStore(); + + await expect( + store.set("alpha", "oauth-client-secret", "v"), + ).rejects.toBeInstanceOf(mod.KeychainUnavailableError); + await expect(store.deleteAllForServer("alpha")).resolves.toBeUndefined(); + }); + + it("treats a namespace that throws on member access as unavailable", async () => { + // Reading a missing export is not always a harmless `undefined` — a + // Proxy-backed namespace can throw, and vitest's module mock does. If + // that throw escaped the shape check it would reject the *cached* + // promise, so the check has to absorb it and report unavailable. + const mod = await importWithKeyringShape({}); + const store = new mod.KeyringSecretStore(); + + expect(await store.get("alpha", "oauth-client-secret")).toBe(null); + await expect( + store.set("alpha", "oauth-client-secret", "v"), + ).rejects.toBeInstanceOf(mod.KeychainUnavailableError); + // …and it reaches the user as a packaging problem, not as "install + // libsecret". Returning the raw access error here would have been + // typed correctly but hinted wrongly. + await expect( + store.set("alpha", "oauth-client-secret", "v"), + ).rejects.toThrow(/does not expose the API this build expects/); + await expect( + store.set("alpha", "oauth-client-secret", "v"), + ).rejects.not.toThrow(/libsecret/); + // Absorbed, not escaped: a rejected cached promise would surface here + // as the raw access error instead of the typed one. + await expect(store.deleteAllForServer("alpha")).resolves.toBeUndefined(); + }); + + it("accepts a well-formed namespace", async () => { + // The guard must not reject the shape the package actually ships — + // otherwise it would turn a working keychain into a permanent 503. + const stored = new Map<string, string>(); + class StubEntry { + // Declared explicitly rather than as a constructor parameter + // property: those are disallowed under `erasableSyntaxOnly`. + private readonly account: string; + constructor(_service: string, account: string) { + this.account = account; + } + async getPassword(): Promise<string | undefined> { + return stored.get(this.account); + } + async setPassword(value: string): Promise<void> { + stored.set(this.account, value); + } + async deleteCredential(): Promise<boolean> { + return stored.delete(this.account); + } + } + const mod = await importWithKeyringShape({ + AsyncEntry: StubEntry, + findCredentialsAsync: async () => [], + }); + const store = new mod.KeyringSecretStore(); + + await store.set("alpha", "oauth-client-secret", "shh"); + expect(await store.get("alpha", "oauth-client-secret")).toBe("shh"); + }); }); diff --git a/clients/web/src/test/integration/mcp/duplicate-tool-names.test.ts b/clients/web/src/test/integration/mcp/duplicate-tool-names.test.ts new file mode 100644 index 000000000..86b2bd9da --- /dev/null +++ b/clients/web/src/test/integration/mcp/duplicate-tool-names.test.ts @@ -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); + }); +}); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-response-rejected.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-response-rejected.test.ts new file mode 100644 index 000000000..4f0b1c510 --- /dev/null +++ b/clients/web/src/test/integration/mcp/inspectorClient-response-rejected.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { MessageLogState } from "@inspector/core/mcp/state/index.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import type { MessageEntry } from "@inspector/core/mcp/types.js"; + +/** + * `markResponseRejected` correlation (#1953). + * + * A response the server answered successfully but the client then refused (the + * SDK codec rejecting the result for the negotiated era) carries no request id + * in the SDK's error, so the client recovers it from the last response received + * for that method. These tests drive a REAL connection so the correlation runs + * against real JSON-RPC ids assigned by the SDK, not hand-built fixtures. + */ +describe("InspectorClient.markResponseRejected", () => { + let client: InspectorClient | null = null; + let log: MessageLogState | null = null; + + beforeEach(async () => { + const serverCommand = getTestMcpServerCommand(); + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { environment: { transport: createTransportNode } }, + ); + log = new MessageLogState(client); + await client.connect(); + }); + + afterEach(async () => { + log?.destroy(); + log = null; + try { + await client?.disconnect(); + } catch { + // Ignore teardown failures — the assertion already ran. + } + client = null; + }); + + function entriesFor(method: string): MessageEntry[] { + return (log?.getMessages() ?? []).filter( + (entry) => "method" in entry.message && entry.message.method === method, + ); + } + + it("annotates the entry for the method's most recent response", async () => { + await client!.listTools(); + const before = entriesFor("tools/list"); + expect(before).toHaveLength(1); + expect(before[0]?.clientError).toBeUndefined(); + + client!.markResponseRejected("tools/list", "ttlMs required"); + + expect(entriesFor("tools/list")[0]?.clientError).toBe("ttlMs required"); + }); + + it("annotates only the latest call, leaving earlier ones untouched", async () => { + await client!.listTools(); + await client!.listTools(); + const entries = entriesFor("tools/list"); + expect(entries).toHaveLength(2); + + client!.markResponseRejected("tools/list", "second one failed"); + + expect(entries[0]?.clientError).toBeUndefined(); + expect(entries[1]?.clientError).toBe("second one failed"); + }); + + it("does not cross method boundaries", async () => { + await client!.listTools(); + await client!.listPrompts(); + + client!.markResponseRejected("tools/list", "tools failed"); + + expect(entriesFor("tools/list")[0]?.clientError).toBe("tools failed"); + expect(entriesFor("prompts/list")[0]?.clientError).toBeUndefined(); + }); + + it("is a no-op for a method nothing has answered", () => { + // No throw, and nothing annotated — a list that never reached the wire + // (e.g. a capability-gated one) must not mislabel some other entry. + expect(() => + client!.markResponseRejected("resources/list", "never happened"), + ).not.toThrow(); + expect((log?.getMessages() ?? []).some((entry) => entry.clientError)).toBe( + false, + ); + }); +}); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts index d10610a27..8c2a005a9 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts @@ -8,6 +8,7 @@ import { type TestServerHttp, createTestServerInfo, createNumberedResources, + createEchoTool, } from "@modelcontextprotocol/inspector-test-server"; import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server"; import type { MessageEntry } from "@inspector/core/mcp/types.js"; @@ -23,6 +24,13 @@ describe("resource subscriptions era fork (#1630)", () => { let server: TestServerHttp | null = null; const RESOURCE_URI = "test://resource_0"; + /** + * Every list-change opt-in off. The listen filter is config ∩ capability and + * the SDK server advertises `listChanged` for every list it registers, so this + * is what leaves the filter empty when nothing is subscribed — i.e. the + * pre-#1920 world these lifecycle tests were written against. + */ + const NO_LIST_CHANGED = { tools: false, resources: false, prompts: false }; const RESOURCE_URI_2 = "test://resource_1"; afterEach(async () => { @@ -59,15 +67,28 @@ describe("resource subscriptions era fork (#1630)", () => { return started; } + /** + * @param listChangedNotifications the Inspector-side opt-ins. The SDK server + * advertises `listChanged` for every list it registers, so this — not the + * server config — is how a test gets an *empty* listen filter: the filter is + * config ∩ capability, and with every opt-in off only a subscribed URI can + * open the stream (#1920). + */ async function connect( url: string, era: "legacy" | "modern", + listChangedNotifications?: { + tools?: boolean; + resources?: boolean; + prompts?: boolean; + }, ): Promise<{ connected: InspectorClient; messages: MessageEntry[] }> { const connected = new InspectorClient( { type: "streamable-http", url }, { environment: { transport: createTransportNode }, versionNegotiation: eraToVersionNegotiation(era), + ...(listChangedNotifications ? { listChangedNotifications } : {}), }, ); const messages: MessageEntry[] = []; @@ -103,6 +124,34 @@ describe("resource subscriptions era fork (#1630)", () => { return params.notifications as Record<string, unknown> | undefined; } + /** + * The private members these tests drive directly. `InspectorClient` declares + * every one of them, so the shape below is a faithful mirror rather than a + * reinterpretation — the double cast is only to reach past `private`, which no + * single `as` can do (the two types share no public overlap). It is confined to + * `internals()` so there is one such cast in the file, and it is unavoidable: + * these are lifecycle branches no public API can reach against a healthy server + * (there is no way to ask for a *remote* stream close, or for a `listen()` that + * fails). + */ + interface StreamInternals { + client: { listen: (...args: unknown[]) => Promise<McpSubscription> }; + modernSubscription: McpSubscription | null; + modernListenGeneration: number; + modernReconnectAttempts: number; + subscribedResources: Set<string>; + refreshModernSubscription(fromReconnect?: boolean): Promise<void>; + onModernSubscriptionClosed( + subscription: McpSubscription, + reason: "local" | "graceful" | "remote", + generation: number, + ): void; + } + + function internals(c: InspectorClient): StreamInternals { + return c as unknown as StreamInternals; + } + describe("modern era", () => { it("opens an acknowledged listen stream on subscribe (no resources/subscribe)", async () => { const started = await startServer({}); @@ -125,15 +174,43 @@ describe("resource subscriptions era fork (#1630)", () => { }); it("closes the stream when the last subscription is removed", async () => { + // Nothing else in the filter (no list-change opt-ins), so the last URI + // leaving empties it and the stream closes (#1920). const started = await startServer({}); - const { connected } = await connect(started.url, "modern"); + const { connected, messages } = await connect( + started.url, + "modern", + NO_LIST_CHANGED, + ); await connected.subscribeToResource(RESOURCE_URI); expect(connected.getResourceSubscriptionStreamState().active).toBe(true); + messages.length = 0; await connected.unsubscribeFromResource(RESOURCE_URI); const streamState = connected.getResourceSubscriptionStreamState(); expect(streamState.active).toBe(false); expect(connected.getSubscribedResources()).toEqual([]); + // No re-listen: there is nothing left to listen for. + expect(methodsSent(messages)).not.toContain("subscriptions/listen"); + }); + + it("keeps the stream open past the last subscription when a listChanged opt-in remains", async () => { + // The other half of the above: with an advertised listChanged the filter + // is still non-empty, so the last unsubscribe re-lists rather than + // closing — and the state goes inactive because the *Subscriptions* + // section has nothing to report, not because the stream is gone (#1920). + const started = await startServer({}); + const { connected, messages } = await connect(started.url, "modern"); + await connected.subscribeToResource(RESOURCE_URI); + + messages.length = 0; + await connected.unsubscribeFromResource(RESOURCE_URI); + + expect(connected.getSubscribedResources()).toEqual([]); + expect(connected.getResourceSubscriptionStreamState().active).toBe(false); + const filter = lastListenFilter(messages); + expect(filter?.resourcesListChanged).toBe(true); + expect(filter?.resourceSubscriptions).toBeUndefined(); }); it("re-lists (stream stays open) when one of several URIs is removed", async () => { @@ -197,6 +274,244 @@ describe("resource subscriptions era fork (#1630)", () => { }); }); + // The stream used to be reachable only by subscribing to a resource, so a + // server with no resources could never open it — and its `tools.listChanged` + // notifications, which ride the same stream, could never arrive (#1920). + describe("modern era: opening the stream for listChanged alone (#1920)", () => { + async function startToolsOnlyServer( + listChanged: ServerConfig["listChanged"], + ): Promise<TestServerHttp> { + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("tools-only-test", "1.0.0"), + tools: [createEchoTool()], + listChanged, + modern: {}, + }); + await started.start(); + server = started; + return started; + } + + it("opens the stream on connect against a tools-only server", async () => { + const started = await startToolsOnlyServer({ tools: true }); + const { connected, messages } = await connect(started.url, "modern"); + + expect(connected.getCapabilities()?.resources).toBeUndefined(); + expect(methodsSent(messages)).toContain("subscriptions/listen"); + const filter = lastListenFilter(messages); + expect(filter?.toolsListChanged).toBe(true); + // Nothing is subscribed, so the filter carries no URIs at all. + expect(filter?.resourceSubscriptions).toBeUndefined(); + // …and the Subscriptions section still has nothing to report. + expect(connected.getSubscribedResources()).toEqual([]); + expect(connected.getResourceSubscriptionStreamState().active).toBe(false); + }); + + it("leaves the stream closed when the opt-in is disabled in config", async () => { + // The filter is config ∩ capability, so turning the handler off in the + // Inspector's own options is enough to keep the stream closed. + const started = await startToolsOnlyServer({ tools: true }); + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation("modern"), + listChangedNotifications: { tools: false }, + }, + ); + const messages: MessageEntry[] = []; + connected.addEventListener("message", (event) => { + messages.push(event.detail); + }); + await connected.connect(); + client = connected; + + expect(methodsSent(messages)).not.toContain("subscriptions/listen"); + }); + + it("opens no stream on the legacy era", async () => { + // Legacy has no `subscriptions/listen` at all — list-change notifications + // arrive on the session's own channel. + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("tools-only-legacy-test", "1.0.0"), + tools: [createEchoTool()], + listChanged: { tools: true }, + }); + await started.start(); + server = started; + const { connected, messages } = await connect(started.url, "legacy"); + + expect(connected.getProtocolEra()).toBe("legacy"); + expect(methodsSent(messages)).not.toContain("subscriptions/listen"); + expect(connected.getResourceSubscriptionStreamState().active).toBe(false); + }); + + it("reconnects a dropped listChanged-only stream (no subscriptions to keep it alive)", async () => { + const started = await startToolsOnlyServer({ tools: true }); + const { connected } = await connect(started.url, "modern"); + + const int = internals(connected); + const dropped = int.modernSubscription; + expect(dropped).not.toBeNull(); + if (!dropped) return; + + int.onModernSubscriptionClosed( + dropped, + "remote", + int.modernListenGeneration, + ); + // Reconnect-by-re-listen runs on the filter, not on the subscribed set — + // which is empty here, and used to be the reason to give up. + expect(connected.getResourceSubscriptionStreamState().status).toBe( + "reconnecting", + ); + await vi.waitFor(() => { + expect(int.modernSubscription).not.toBeNull(); + }); + expect(connected.getResourceSubscriptionStreamState().status).toBe( + "acknowledged", + ); + }); + + it("has the stream up before the connect event fires", async () => { + // The managed list states start their initial `refresh()` from the + // `connect` event, so the stream has to be established (or its retry + // armed) first: dispatching earlier would let `tools/list` go out ahead of + // `subscriptions/listen`, and a list the server changed in that window + // would notify nobody. + const started = await startToolsOnlyServer({ tools: true }); + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation("modern"), + }, + ); + let streamAtConnectEvent: boolean | undefined; + connected.addEventListener("connect", () => { + streamAtConnectEvent = internals(connected).modernSubscription !== null; + }); + await connected.connect(); + client = connected; + + expect(streamAtConnectEvent).toBe(true); + }); + + it("does not announce the connection when a disconnect overtakes the stream open", async () => { + // The listen round-trip widened the window between the handshake and the + // `connect` announcement, so a `disconnect()` can land inside it. + // Announcing anyway would restart every managed list refresh against a + // session being torn down. + const started = await startToolsOnlyServer({ tools: true }); + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation("modern"), + }, + ); + client = connected; + let connectAnnounced = false; + connected.addEventListener("connect", () => { + connectAnnounced = true; + }); + + // Hold the stream open so the disconnect lands while it is in flight. + // `openStarted` is what makes the race deterministic: the stub is only + // reached after the handshake, so disconnecting before it runs would leave + // `releaseOpen` unset and hang the connect. + const int = internals(connected); + let releaseOpen: () => void = () => {}; + let markOpenStarted: () => void = () => {}; + const openStarted = new Promise<void>((resolve) => { + markOpenStarted = resolve; + }); + int.refreshModernSubscription = () => { + markOpenStarted(); + return new Promise<void>((resolve) => { + releaseOpen = resolve; + }); + }; + + const connecting = connected.connect(); + await openStarted; + const disconnecting = connected.disconnect(); + releaseOpen(); + await connecting; + await disconnecting; + + expect(connectAnnounced).toBe(false); + }); + + it("connects anyway when the connect-time listen fails, and retries", async () => { + // The handshake succeeded; every request-scoped feature works without the + // stream, so the failure is handed to the reconnect machinery instead of + // failing `connect()`. + const started = await startToolsOnlyServer({ tools: true }); + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation("modern"), + }, + ); + // Shadow the private refresh on the instance so the connect-time open + // fails without a live client to reach into (there is none until + // `connect()` builds one). The bump mirrors the real method's contract — + // it claims the generation synchronously before it can fail — which is + // what the caller's guard reads to tell "our refresh failed" from "a newer + // one took over". + const int = internals(connected); + int.refreshModernSubscription = () => { + int.modernListenGeneration++; + return Promise.reject(new Error("listen boom")); + }; + + await expect(connected.connect()).resolves.toBeUndefined(); + client = connected; + + expect(connected.getStatus()).toBe("connected"); + expect(connected.getResourceSubscriptionStreamState().status).toBe( + "reconnecting", + ); + }); + + it("leaves a superseded connect-time failure to the refresh that owns the stream", async () => { + // Nothing about the ordering above makes this call the only refresh that + // can be in flight: `statusChange` has already fired, and a concurrent + // `subscribeToResource` — or a `disconnect()`, whose + // `resetSubscriptionStream` bumps the generation too — can supersede this + // one while its `listen()` is pending. Reconciling anyway would arm a + // reconnect against a stream that is healthy (or a session that is gone), + // so this makes the same ownership test the subscribe/unsubscribe paths + // make. Driven here by a stub that bumps twice, standing in for "someone + // else advanced it". + const started = await startToolsOnlyServer({ tools: true }); + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation("modern"), + }, + ); + const int = internals(connected); + // Two bumps: this call's own, plus the newer refresh that superseded it + // before its failure landed. + int.refreshModernSubscription = () => { + int.modernListenGeneration += 2; + return Promise.reject(new Error("listen boom")); + }; + + await expect(connected.connect()).resolves.toBeUndefined(); + client = connected; + + // No reconnect armed: the state is the newer refresh's to write. + expect(connected.getResourceSubscriptionStreamState().status).not.toBe( + "reconnecting", + ); + }); + }); + describe("legacy era", () => { it("subscribes via resources/subscribe with no listen stream", async () => { const started = await startServer(undefined); @@ -245,23 +560,6 @@ describe("resource subscriptions era fork (#1630)", () => { // the client's private state — the pattern used across the InspectorClient // coverage-backfill suite — to drive them deterministically. describe("modern stream internals", () => { - interface StreamInternals { - client: { listen: (...args: unknown[]) => Promise<McpSubscription> }; - modernSubscription: McpSubscription | null; - modernListenGeneration: number; - modernReconnectAttempts: number; - subscribedResources: Set<string>; - onModernSubscriptionClosed( - subscription: McpSubscription, - reason: "local" | "graceful" | "remote", - generation: number, - ): void; - } - - function internals(c: InspectorClient): StreamInternals { - return c as unknown as StreamInternals; - } - /** A controllable fake `McpSubscription` whose `closed` we resolve on demand. */ function makeFakeSub(): { sub: McpSubscription; @@ -355,8 +653,17 @@ describe("resource subscriptions era fork (#1630)", () => { // Reachable without any close() failure: a rejecting `listen()` does it, // which is what a user subscribing while the reconnect timer re-lists // (i.e. exactly when the server is flaky) can produce. + // + // No list-change opt-ins, so connect leaves the stream closed (#1920) and + // the first subscribe's `listen()` is reached synchronously — with a + // stream to tear down first, the swap below would land after this call + // already read `client.listen`. const started = await startServer({}); - const { connected } = await connect(started.url, "modern"); + const { connected } = await connect( + started.url, + "modern", + NO_LIST_CHANGED, + ); const int = internals(connected); const real = int.client.listen; @@ -504,8 +811,15 @@ describe("resource subscriptions era fork (#1630)", () => { }); it("reflects the subscription optimistically as 'connecting' before the ack", async () => { + // No list-change opt-ins → no connect-time stream, so the subscribe's + // `listen()` is the first one and this test's held ack is the one it waits + // on (#1920). const started = await startServer({}); - const { connected } = await connect(started.url, "modern"); + const { connected } = await connect( + started.url, + "modern", + NO_LIST_CHANGED, + ); const int = internals(connected); // Hold the listen ack so we can observe the pre-ack (optimistic) state. @@ -632,8 +946,14 @@ describe("resource subscriptions era fork (#1630)", () => { }); it("does not reconnect when the subscription set empties before the timer fires", async () => { + // No list-change opt-ins, so emptying the set empties the *filter* — + // which is what the timer's bail now tests (#1920). const started = await startServer({}); - const { connected } = await connect(started.url, "modern"); + const { connected } = await connect( + started.url, + "modern", + NO_LIST_CHANGED, + ); await connected.subscribeToResource(RESOURCE_URI); const int = internals(connected); const fake = await installFakeSubscription(int); diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 7937b219a..8c05b7ed0 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { resolve } from "node:path"; import * as z from "zod/v4"; import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; import { @@ -45,6 +46,8 @@ import { createAddResourceTool, createAddToolTool, createAddPromptTool, + loadConfig, + resolveConfig, } from "@modelcontextprotocol/inspector-test-server"; import type { MessageEntry, @@ -95,6 +98,69 @@ async function getTool(client: InspectorClient, name: string): Promise<Tool> { throw new Error(`Tool ${name} not found`); } +/** + * Hold a deliberately un-awaited in-flight call so its rejection is handled. + * + * A few tests start a tool call, assert on the notifications it streams, and + * then tear the connection down while the call is still in flight. That is a + * legitimate thing to exercise, but `disconnect()` closes the SDK client, which + * rejects every pending request with "Connection closed" — and a floating + * promise makes that an *unhandled* rejection, which vitest counts as a run + * error and fails `npm run ci` even though every test passes (#1947). + * + * Attach the handler at call time (not after the assertions) so there is no + * window in which the rejection can escape, then finish through + * `disconnectAndSettle()`, which tears down and awaits the call in one step. + * + * Only a `CONNECTION_CLOSED` raised *by that teardown* is absorbed. Plain + * fulfillment is fine too — whether the call beats the teardown is a race, so + * asserting either outcome would turn this straight back into a flake. Every + * other rejection is re-thrown, including a `CONNECTION_CLOSED` that arrives + * before teardown begins: a transport that drops on its own after emitting the + * progress notifications is a real regression, and absorbing it would let these + * tests pass on the strength of the notifications alone. + * + * The teardown flag is owned by the helper and set inside `disconnectAndSettle` + * rather than by the caller, so the flag cannot be raised too early (which would + * reopen the hole) and the await cannot be forgotten. + */ +interface InFlightCall { + /** Disconnect, then await the call — absorbing only this teardown's close. */ + disconnectAndSettle(client: InspectorClient): Promise<void>; +} + +function settleInFlight(call: Promise<unknown>): InFlightCall { + let tearingDown = false; + const settled = call.then( + () => undefined, + (error: unknown) => { + if ( + tearingDown && + error instanceof SdkError && + error.code === SdkErrorCode.ConnectionClosed + ) { + return; + } + throw error; + }, + ); + // `then` returns a *derived* promise, and the re-throw above rejects that one + // — not `call`. The caller does not await it until after `disconnect()`, so an + // unexpected rejection arriving while the test is still waiting on progress + // notifications would sit unobserved for seconds and be reported as an + // unhandled rejection: precisely the failure this helper exists to prevent. + // Observe it the moment it exists. This does not swallow anything — `settled` + // stays rejected, so the caller's `await` below still fails the test. + settled.catch(() => undefined); + return { + async disconnectAndSettle(client: InspectorClient): Promise<void> { + tearingDown = true; + await client.disconnect(); + await settled; + }, + }; +} + /** Get all resources from the client via listResources() (paginates if needed). */ async function getAllResources( client: InspectorClient, @@ -1214,6 +1280,52 @@ describe("InspectorClient", () => { }); }); + describe("Structured output showcase config (#1908)", () => { + // Drives the shipped `structured-output-http.json` end to end — the file + // itself, the `list_items` preset registration, and the fixture's nested + // payload. A typo in any of the three fails here rather than leaving the + // documented showcase quietly broken. + const showcaseConfigPath = resolve( + import.meta.dirname, + "../../../../../../test-servers/configs/structured-output-http.json", + ); + + it("serves list_items with a summary block and a nested structuredContent", async () => { + server = createTestServerHttp( + resolveConfig(loadConfig(showcaseConfigPath)), + ); + await server.start(); + + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { environment: { transport: createTransportNode } }, + ); + await client.connect(); + + const tool = await getTool(client, "list_items"); + expect(tool.outputSchema).toBeDefined(); + + const result = await client.callTool(tool, {}); + expect(result.success).toBe(true); + + // The text block only summarizes — the payload is the structured half. + const content = result.result!.content as Array<{ + type: string; + text?: string; + }>; + expect(content[0].type).toBe("text"); + expect(content[0].text).toBe("Found 2 items."); + + expect(result.result!.structuredContent).toEqual({ + items: [ + { id: 1, name: "Item A", tags: ["foo", "bar"] }, + { id: 2, name: "Item B", tags: ["baz"] }, + ], + total: 2, + }); + }); + }); + describe("Default metadata (server-wide _meta)", () => { function metaOf(req: { message: unknown }): Record<string, unknown> { const params = (req.message as { params?: { _meta?: unknown } }).params; @@ -2221,16 +2333,18 @@ describe("InspectorClient", () => { const progressToken = 12345; const sendProgressTool = await getTool(client, "send_progress"); - client.callTool( - sendProgressTool, - { - units: 3, - delayMs: 50, - total: 3, - message: "Test progress", - }, - undefined, // generalMetadata - { progressToken: progressToken.toString() }, // toolSpecificMetadata + const inFlight = settleInFlight( + client.callTool( + sendProgressTool, + { + units: 3, + delayMs: 50, + total: 3, + message: "Test progress", + }, + undefined, // generalMetadata + { progressToken: progressToken.toString() }, // toolSpecificMetadata + ), ); const progressEvents = await waitForProgressCount(client, 3, { @@ -2261,7 +2375,7 @@ describe("InspectorClient", () => { progressToken: progressToken.toString(), }); - await client!.disconnect(); + await inFlight.disconnectAndSettle(client!); await server.stop(); }); @@ -2349,15 +2463,17 @@ describe("InspectorClient", () => { const progressToken = 67890; const sendProgressTool2 = await getTool(client, "send_progress"); - client.callTool( - sendProgressTool2, - { - units: 2, - delayMs: 50, - message: "Indeterminate progress", - }, - undefined, // generalMetadata - { progressToken: progressToken.toString() }, // toolSpecificMetadata + const inFlight = settleInFlight( + client.callTool( + sendProgressTool2, + { + units: 2, + delayMs: 50, + message: "Indeterminate progress", + }, + undefined, // generalMetadata + { progressToken: progressToken.toString() }, // toolSpecificMetadata + ), ); const progressEvents = await waitForProgressCount(client, 2, { @@ -2379,7 +2495,7 @@ describe("InspectorClient", () => { }); expect((progressEvents[1] as { total?: number }).total).toBeUndefined(); - await client!.disconnect(); + await inFlight.disconnectAndSettle(client!); await server.stop(); }); diff --git a/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts b/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts index 5f992d45a..355f5bb85 100644 --- a/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts @@ -76,8 +76,79 @@ async function startUnauthorizedUpstream(): Promise<{ return { url: `http://127.0.0.1:${port}/mcp`, server }; } -/** Connect a stdio session whose process crashes almost immediately, then - * give the onclose handler time to mark the session's transport dead. */ +/** + * Poll until the server reports the session's transport as dead (#1985). + * + * The probe is `/api/mcp/auth-state`, chosen because for a session connected + * *without* an authState it answers `kind: "transport_error"` from exactly one + * place — the `isTransportDead()` short-circuit. Every other outcome is a 400 + * from `setAuthState` ("Session has no OAuth auth provider"), so a + * `transport_error` here is proof that `onclose` has already run + * `markTransportDead()`, not merely that some write failed. + * + * That single source is the point. `/api/mcp/send` looks like the obvious probe + * but answers `transport_error` from *two* branches: the dead-transport + * short-circuit, and the `catch` around a `send()` that rejected. Only the first + * implies the transport is marked dead, so a stop condition keyed on the + * response cannot tell them apart. It happens to be safe today — + * `StdioClientTransport.send` rejects only when `_process` is undefined, and the + * transport clears `_process` and calls `onclose` in one synchronous block, so + * the `catch` branch cannot be observed before `markTransportDead()` has run — + * but that is an SDK-internal ordering detail, not something this test states or + * controls. If it ever changed, an early return here would hand each call site a + * live session: the send test would quietly exercise the `catch` branch instead + * of the short-circuit it names, and the auth-state test would fall through to + * `setAuthState` and fail on a 400. Probing a single-source route costs nothing + * and does not depend on the ordering holding. + * + * The route also cannot hang: it never awaits a response from the transport, so + * unlike a *request* through `/api/mcp/send` there is nothing here to wait + * forever on a reply the dead child will never send. + * + * Deliberately not `/api/mcp/events`: opening that stream on a dead transport + * calls `sessions.delete(sessionId)`, so the send under test would then answer + * 404 instead of the `transport_error` it is asserting. + */ +async function waitForDeadTransport( + h: Harness, + sessionId: string, + timeoutMs = 10_000, +): Promise<void> { + const deadline = Date.now() + timeoutMs; + let last = "(no response)"; + while (Date.now() < deadline) { + const res = await fetch(`${h.baseUrl}/api/mcp/auth-state`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId, + authState: { + oauthTokens: { access_token: "probe", token_type: "Bearer" }, + }, + }), + }); + const body = (await res.json()) as { kind?: string }; + if (body.kind === "transport_error") return; + last = `HTTP ${res.status} ${JSON.stringify(body)}`; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error( + `transport was never reported dead within ${timeoutMs}ms; last probe: ${last}`, + ); +} + +/** + * Connect a stdio session whose process crashes almost immediately, then wait + * until the transport is actually marked dead. + * + * This used to sleep a flat 300ms, which had to cover a cold `spawn()`, node's + * startup, its exit, the parent observing the close, and `onclose` marking the + * session dead. Comfortable on an idle machine; not under CI contention. When + * the budget was missed the session was still live, so the send under test was + * dispatched to a transport whose child was gone and hung for the project's full + * 30s timeout (#1985). Waiting on the observable condition costs latency on a + * slow machine instead of a false failure. + */ async function connectDeadSession(h: Harness): Promise<string> { const config: MCPServerConfig = { type: "stdio", @@ -87,9 +158,7 @@ async function connectDeadSession(h: Harness): Promise<string> { const res = await connect(h, config); expect(res.status).toBe(200); const { sessionId } = (await res.json()) as { sessionId: string }; - // Give the subprocess time to exit and the transport's onclose handler - // to mark the session dead (mirrors connect-crash.test.ts's technique). - await new Promise((resolve) => setTimeout(resolve, 300)); + await waitForDeadTransport(h, sessionId); return sessionId; } diff --git a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts index 5fe6f4dec..c152b960b 100644 --- a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts @@ -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); diff --git a/clients/web/src/test/integration/mcp/remote/sse-priming.test.ts b/clients/web/src/test/integration/mcp/remote/sse-priming.test.ts new file mode 100644 index 000000000..1735d0274 --- /dev/null +++ b/clients/web/src/test/integration/mcp/remote/sse-priming.test.ts @@ -0,0 +1,159 @@ +/** + * Regression tests for #1858 — the web UI hanging forever on "Connecting…" + * in Firefox. + * + * Firefox does not hand a streaming `fetch()` response to JS until the first + * *body* byte arrives (Chromium resolves on headers). Both SSE endpoints used + * to flush headers and then stay silent until there was something to report, + * which deadlocked `/api/mcp/events`: the browser transport awaits that fetch + * before the MCP client sends `initialize`, so nothing was ever reported and + * the fetch never resolved. + * + * The fix is a priming SSE comment written the instant each stream opens. + * These tests assert the bytes land on an otherwise-idle stream — the + * behavior the browser depends on — rather than any particular payload. + */ + +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { serve } from "@hono/node-server"; +import type { ServerType } from "@hono/node-server"; +import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js"; +import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; +import { closeHarnessServer } from "./harnessTeardown.js"; + +interface Harness { + baseUrl: string; + server: ServerType; + storageDir: string; +} + +async function setup(): Promise<Harness> { + const storageDir = mkdtempSync(join(tmpdir(), "sse-priming-")); + const { app } = createRemoteApp({ + dangerouslyOmitAuth: true, + storageDir, + initialConfig: { defaultEnvironment: {} }, + }); + return new Promise((resolve, reject) => { + const server = serve( + { fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, + (info) => { + const port = + info && typeof info === "object" && "port" in info + ? (info as { port: number }).port + : 0; + resolve({ baseUrl: `http://127.0.0.1:${port}`, server, storageDir }); + }, + ); + server.on("error", reject); + }); +} + +/** + * Read the first body chunk off a streaming response, or reject if none + * arrives within `timeoutMs`. Pre-fix, an idle stream produced no chunk at + * all — which is exactly what stalled Firefox — so the timeout is the + * assertion that matters here. + */ +async function firstChunk(res: Response, timeoutMs = 3000): Promise<string> { + if (!res.body) throw new Error("response has no body"); + const reader = res.body.getReader(); + try { + const read = reader.read().then(({ value }) => { + return value ? new TextDecoder().decode(value) : ""; + }); + const timeout = new Promise<never>((_, reject) => { + setTimeout( + () => reject(new Error(`no body bytes within ${timeoutMs}ms`)), + timeoutMs, + ); + }); + return await Promise.race([read, timeout]); + } finally { + await reader.cancel().catch(() => { + /* stream already torn down */ + }); + } +} + +/** The inert SSE comment frame the backend primes each stream with. */ +const PRIMING_FRAME = ":\n\n"; + +describe("SSE streams are primed on open (#1858)", () => { + let h: Harness; + + beforeEach(async () => { + h = await setup(); + }); + + afterEach(async () => { + await closeHarnessServer(h.server); + rmSync(h.storageDir, { recursive: true, force: true }); + }); + + it("GET /api/mcp/events flushes bytes before any MCP traffic", async () => { + // A subprocess that spawns and stays alive but never speaks — the state + // the session is in between `connect` and the client's `initialize`. + // This is precisely the window in which the deadlock occurred. + const config: MCPServerConfig = { + type: "stdio", + command: process.execPath, + args: ["-e", "setInterval(() => {}, 1000);"], + }; + + const connectRes = await fetch(`${h.baseUrl}/api/mcp/connect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ config }), + }); + expect(connectRes.status).toBe(200); + const { sessionId } = (await connectRes.json()) as { sessionId: string }; + + const controller = new AbortController(); + try { + const eventsRes = await fetch( + `${h.baseUrl}/api/mcp/events?sessionId=${sessionId}`, + { signal: controller.signal }, + ); + expect(eventsRes.status).toBe(200); + + expect(await firstChunk(eventsRes)).toBe(PRIMING_FRAME); + } finally { + controller.abort(); + await fetch(`${h.baseUrl}/api/mcp/disconnect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId }), + }).catch(() => { + /* best-effort teardown */ + }); + } + }); + + it("GET /api/servers/events flushes bytes before any file change", async () => { + const controller = new AbortController(); + try { + const res = await fetch(`${h.baseUrl}/api/servers/events`, { + signal: controller.signal, + }); + expect(res.status).toBe(200); + + expect(await firstChunk(res)).toBe(PRIMING_FRAME); + } finally { + controller.abort(); + } + }); + + it("primes with an SSE comment, which carries no event or data field", () => { + // The priming frame must be inert: a conforming parser drops it, so no + // client needs to know about it. Guards against it ever being changed + // into something a consumer would mistake for a real event. + const lines = PRIMING_FRAME.split("\n"); + expect(lines.some((l) => l.startsWith("data:"))).toBe(false); + expect(lines.some((l) => l.startsWith("event:"))).toBe(false); + expect(lines[0]).toBe(":"); + }); +}); diff --git a/clients/web/src/test/integration/mcp/remote/transport.test.ts b/clients/web/src/test/integration/mcp/remote/transport.test.ts index d66ae36a5..2e2b55ec4 100644 --- a/clients/web/src/test/integration/mcp/remote/transport.test.ts +++ b/clients/web/src/test/integration/mcp/remote/transport.test.ts @@ -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 diff --git a/clients/web/src/test/leakedTimers.test.tsx b/clients/web/src/test/leakedTimers.test.tsx new file mode 100644 index 000000000..65445338b --- /dev/null +++ b/clients/web/src/test/leakedTimers.test.tsx @@ -0,0 +1,104 @@ +/** + * Regression tests for the leaked-timer safety net in `setup.ts` (#1984). + * + * A `window.setTimeout` that outlives its test file fires after happy-dom has + * disposed that file's `window`, and React then throws an uncaught + * `ReferenceError: window is not defined` that fails the whole run — from an + * arbitrary innocent file, with every test passing. These lock down the net that + * prevents it. + * + * Note what is deliberately NOT asserted: that Mantine schedules no timers. It + * does (measured: three 200ms timers when a `Modal` opens, even under + * `env="test"`), and that is the library behaving normally. The contract here is + * only that nothing survives the test that scheduled it. + */ + +import { describe, it, expect, vi } from "vitest"; +import { Modal } from "@mantine/core"; +import { renderWithMantine } from "./renderWithMantine"; +import { pendingTimerCount } from "./setup"; + +/** Schedule through the wrapper the net installed, keeping its inferred handle + * type so it stays directly acceptable to `window.clearTimeout`. */ +function scheduleTracked(ms: number) { + return window.setTimeout(() => {}, ms); +} + +describe("leaked-timer safety net", () => { + it("wraps window.setTimeout rather than leaving the native one in place", () => { + // If the wrapper were absent the net would silently track nothing, so assert + // the instrumentation exists before relying on the behavior it enables. + expect(window.setTimeout.toString()).not.toContain("[native code]"); + }); + + it("clears a timer the test leaves pending, so it cannot fire later", async () => { + const fired = vi.fn(); + window.setTimeout(fired, 20); + // Deliberately do not clear it: the afterEach net owns it from here. The + // next test asserts it never ran. + leaked.callback = fired; + expect(fired).not.toHaveBeenCalled(); + }); + + it("the previous test's leaked timer never fired", async () => { + // 20ms of real time, comfortably past the leaked timer's deadline. + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(leaked.callback).not.toBeNull(); + expect(leaked.callback).not.toHaveBeenCalled(); + }); + + it("clearTimeout untracks the timer", () => { + // Asserted against the net's own bookkeeping, not against "clearTimeout + // doesn't throw" — the latter holds whether or not tracking works, since + // clearTimeout is idempotent, so it proved nothing. That vacuity hid a real + // bug: happy-dom's handle is a `Timeout` object, so the original + // `typeof id === "number"` guard never matched and nothing was untracked. + const before = pendingTimerCount(); + const id = scheduleTracked(1000); + expect(pendingTimerCount()).toBe(before + 1); + window.clearTimeout(id); + expect(pendingTimerCount()).toBe(before); + }); + + it("queues a frame that would schedule a timer after the sweep", () => { + // The rAF race in miniature, and the ordering the net depends on. This + // synchronous test cannot let the frame callback run — it fires only after + // the test (and the whole afterEach) returns. If frames were not cancelled + // *before* the timer sweep, this callback would register `rafScheduled` + // after the drain and survive teardown. The next test proves it does not. + requestAnimationFrame(() => { + rafRace.ranFrame = true; + window.setTimeout(() => { + rafRace.ranTimer = true; + }, 5); + }); + expect(rafRace.ranFrame).toBe(false); + }); + + it("neither the queued frame nor its timer ever ran", async () => { + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(rafRace.ranFrame).toBe(false); + expect(rafRace.ranTimer).toBe(false); + }); + + it("a Modal's own transition timers do not survive the test that opened it", () => { + // The real-world shape of #1984: ServerRemoveConfirmModal-style tests toggle + // `opened`, and Mantine schedules real timers for the transition and the + // scroll lock. Rendering here is enough — the net clears them at teardown, + // and a regression would surface as the run-killing uncaught ReferenceError + // rather than as a failure of this assertion. + const { rerender } = renderWithMantine( + <Modal opened={false} onClose={() => {}} />, + ); + rerender(<Modal opened={true} onClose={() => {}} />); + expect(document.body).toBeTruthy(); + }); +}); + +/** Carries the leaked callback across the two tests above. */ +const leaked: { callback: ReturnType<typeof vi.fn> | null } = { + callback: null, +}; + +/** Records whether a queued frame — or the timer it would register — ever ran. */ +const rafRace = { ranFrame: false, ranTimer: false }; diff --git a/clients/web/src/test/renderWithMantine.tsx b/clients/web/src/test/renderWithMantine.tsx index 61c91077e..ea291c6b2 100644 --- a/clients/web/src/test/renderWithMantine.tsx +++ b/clients/web/src/test/renderWithMantine.tsx @@ -37,13 +37,20 @@ function makeWrapper(env: "test" | "default", colorScheme: MantineColorScheme) { }; } -// Default render helper. `env="test"` makes Mantine render transitions -// synchronously (no internal `setTimeout`). Without it, a `Transition`/`Modal` -// open/close timer can fire after happy-dom tears down `window` at the end of -// the run, throwing an uncaught `ReferenceError: window is not defined` that -// fails the whole run even when every assertion passed (#1760). This is the -// right default for the vast majority of tests, which don't assert on -// mid-transition state. +// Default render helper. `env="test"` makes Mantine skip the *animated render* +// of a transition, which is the right default for the vast majority of tests — +// they don't assert on mid-transition state. +// +// It does NOT stop the timers, contrary to what this comment used to claim +// (#1984). `env` is read only by `Transition.mjs`, at its render branch +// (`transitionDuration === 0 || env === "test"`), while `useTransition()` runs +// before that check — hooks cannot be conditional — and still schedules real +// `window.setTimeout`s on every `mounted` change. Measured: opening a `<Modal>` +// through this helper schedules three 200ms timers. A timer that outlives its +// file fires after happy-dom disposes that file's `window` and throws an +// uncaught `ReferenceError: window is not defined`, failing the whole run from +// an arbitrary innocent file (#1760). What actually prevents that is the +// leaked-timer safety net in `setup.ts`. export function renderWithMantine( ui: ReactElement, options?: MantineRenderOptions, diff --git a/clients/web/src/test/setup.ts b/clients/web/src/test/setup.ts index d818a2cec..c86b36344 100644 --- a/clients/web/src/test/setup.ts +++ b/clients/web/src/test/setup.ts @@ -65,10 +65,15 @@ Object.defineProperty(globalThis, "fetch", { // disable Mantine transitions: `useTransition` only honors reduced motion when // `theme.respectReducedMotion` is true, and Mantine 8 defaults it to `false` // (the project theme doesn't override it). The actual protection against the -// #1760 post-teardown `window is not defined` leak is `env="test"` in -// `renderWithMantine`, which forces transitions synchronous regardless. Tests -// that need specific media results still mock `@mantine/hooks` or stub -// `matchMedia` themselves. +// #1760 post-teardown `window is not defined` leak is the leaked-timer safety +// net below — NOT `env="test"`, which an earlier version of this comment +// claimed. `env` is read only by `Transition.mjs`, and only at its *render* +// branch (`transitionDuration === 0 || env === "test"`); `useTransition()` is +// called before that check, since hooks cannot be conditional, so it still +// schedules real `window.setTimeout`s on every `mounted` change. Measured: +// opening a `<Modal>` through `renderWithMantine` schedules three 200ms timers +// (#1984). Tests that need specific media results still mock `@mantine/hooks` +// or stub `matchMedia` themselves. Object.defineProperty(window, "matchMedia", { configurable: true, writable: true, @@ -91,7 +96,143 @@ Object.defineProperty(window, "matchMedia", { }) as unknown as MediaQueryList, }); +// --------------------------------------------------------------------------- +// Leaked-timer safety net (#1984) +// +// A `window.setTimeout` that outlives its test file fires after happy-dom has +// disposed that file's `window`, and React's `dispatchSetState` then throws an +// uncaught `ReferenceError: window is not defined`. Vitest attributes that to +// whichever file happened to be running, and one such error fails the ENTIRE +// run with every test passing — so it reads as a defect in an innocent file. +// +// Mantine's own hooks do clear their timers on unmount (`useTransition`'s +// `clearAllTimeouts`, `useLockScroll`'s effect cleanup), so this is not a +// library bug. It is a race: `clearAllTimeouts` cancels the *pending* rAF, but +// if that rAF callback is already in flight when the unmount lands, +// `cancelAnimationFrame` is a no-op and the callback goes on to schedule a +// `setTimeout` after cleanup has already run. Nothing owns that timer. It needs +// a loaded machine to hit, which is why it only ever appears in CI. +// +// Rather than chase each component, track every timer *and every animation +// frame*, and clear whatever is still outstanding once the test's own teardown +// has had its turn. Ordering is load-bearing twice over: this runs *after* +// `cleanup()` below, so legitimate unmount cleanups clear their own timers and +// only true leaks are left; and frames are cancelled *before* timers are swept, +// because a queued frame callback would otherwise run after the sweep and +// register a fresh timer. See the ordering note on the `afterEach` itself. +// +// Under `vi.useFakeTimers()` the wrapper is swapped out for vitest's fake +// implementation, so nothing is tracked while fake timers are installed — which +// is correct, since a fake timer cannot outlive the environment. +// Handles are held as `unknown`, deliberately. The DOM lib declares these as +// `number`, but happy-dom returns objects at runtime — verified here: +// `setTimeout` yields a `Timeout` and `requestAnimationFrame` an `Immediate`. +// An earlier revision typed the sets `Set<number>` and guarded removal with +// `typeof id === "number"`; that guard never matched, so explicitly-cleared +// timers were never untracked. Harmless in effect (the teardown sweep clears +// them again and `clearTimeout` is idempotent) but the bookkeeping was a +// fiction, and the test asserting it passed vacuously. Treat the handle as +// opaque and pass it straight back to the matching canceller. +const pendingTimers = new Set<unknown>(); +const pendingFrames = new Set<unknown>(); +const realSetTimeout = window.setTimeout.bind(window); +const realClearTimeout = window.clearTimeout.bind(window); +const realRequestAnimationFrame = window.requestAnimationFrame.bind(window); +const realCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + +/** Cancel an opaque handle through the real canceller it came from. */ +function cancelTimer(id: unknown): void { + // The handle came from `realSetTimeout`, so it is exactly what + // `realClearTimeout` expects; only the *declared* type disagrees. + realClearTimeout(id as Parameters<typeof realClearTimeout>[0]); +} + +function cancelFrame(handle: unknown): void { + // As above, for the rAF pair. + realCancelAnimationFrame( + handle as Parameters<typeof realCancelAnimationFrame>[0], + ); +} + +/** Test-only introspection, so the regression tests can assert on the + * bookkeeping itself rather than on behavior that would hold anyway. */ +export function pendingTimerCount(): number { + return pendingTimers.size; +} + +window.setTimeout = (( + handler: TimerHandler, + timeout?: number, + ...args: unknown[] +): unknown => { + const id: unknown = realSetTimeout( + (...cbArgs: unknown[]) => { + pendingTimers.delete(id); + if (typeof handler === "function") { + handler(...cbArgs); + } + }, + timeout, + ...args, + ); + pendingTimers.add(id); + return id; + // The wrapper's public signature must match the DOM declaration the app codes + // against, while its body traffics in the runtime handle described above. TS + // cannot relate the two, hence the cast. +}) as unknown as typeof window.setTimeout; + +window.clearTimeout = ((id?: unknown): void => { + // No `typeof` guard: the handle is an object here, so a numeric test would + // reject every real one. Anything defined is worth untracking. + if (id !== undefined && id !== null) { + pendingTimers.delete(id); + } + cancelTimer(id); + // Same declaration-vs-runtime mismatch as `setTimeout` above. +}) as unknown as typeof window.clearTimeout; + +window.requestAnimationFrame = ((callback: FrameRequestCallback): unknown => { + const handle: unknown = realRequestAnimationFrame((time: number) => { + pendingFrames.delete(handle); + callback(time); + }); + pendingFrames.add(handle); + return handle; + // Same declaration-vs-runtime mismatch as `setTimeout` above. +}) as unknown as typeof window.requestAnimationFrame; + +window.cancelAnimationFrame = ((handle?: unknown): void => { + if (handle !== undefined && handle !== null) { + pendingFrames.delete(handle); + } + cancelFrame(handle); + // Same declaration-vs-runtime mismatch as `setTimeout` above. +}) as unknown as typeof window.cancelAnimationFrame; + afterEach(() => { cleanup(); window.localStorage.clear(); + + // Order is the whole point, and it is not interchangeable. + // + // Cancel queued animation frames FIRST. This `afterEach` is synchronous, so a + // frame callback already queued cannot run until it returns — at which point + // it would register a fresh `setTimeout` *after* the sweep below had already + // drained, and on the file's last test that timer would survive teardown. + // That is precisely the rAF race this net exists for, so sweeping timers + // without cancelling frames first would leave the original hole open. + // + // Cancelling first closes it deterministically: JS is single-threaded and + // nothing here yields, so no frame callback can run between these two loops. + for (const handle of pendingFrames) { + cancelFrame(handle); + } + pendingFrames.clear(); + + // Then drop the timers. After cleanup(), anything still pending is a leak. + for (const id of pendingTimers) { + cancelTimer(id); + } + pendingTimers.clear(); }); diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index 3b99d672b..a31c01693 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -379,7 +379,15 @@ export default defineConfig(({ command }) => { }, ], }, - setupFiles: [".storybook/vitest.setup.ts"], + // No `setupFiles`: since Storybook 10.3 `@storybook/addon-vitest` + // provisions the preview annotations (`.storybook/preview.tsx` plus + // `@storybook/addon-a11y/preview`) itself, and *skips* doing so when + // it finds a setup file calling `setProjectAnnotations` — so the old + // `.storybook/vitest.setup.ts` was both redundant and actively + // opting out of the automatic path (#1898). A green suite doesn't + // prove the automatic provisioning works (stories rendered without + // the Mantine decorator would very likely still pass), so + // `src/test/PreviewAnnotations.stories.tsx` asserts it directly. }, }, ], diff --git a/core/auth/node/secret-store.ts b/core/auth/node/secret-store.ts index dbc07d791..2ccefb432 100644 --- a/core/auth/node/secret-store.ts +++ b/core/auth/node/secret-store.ts @@ -13,10 +13,124 @@ * browser side never imports this; it gets values rehydrated into the * `/api/servers` response by the Hono handler. */ -import { AsyncEntry, findCredentialsAsync } from "@napi-rs/keyring"; const SERVICE_NAME = "mcp-inspector"; +/** + * `@napi-rs/keyring` ships one prebuilt binary per platform triple, and + * loading the package *throws* on a platform it has no binary for — + * Android / Termux is the reported case (#1905), where the import fails + * with "Cannot find native binding" / "Cannot find module + * '@napi-rs/keyring-android-arm64'". + * + * A static top-level import made that a startup crash: the module never + * evaluated, so the Inspector exited before any of the + * keychain-unavailable handling below could run. Loading it lazily, and + * caching the *outcome* rather than only the module, folds an + * unsupported platform into the same degradation contract as an + * unreachable keychain (see `KeyringSecretStore`) — the store is simply + * unavailable, and callers see it through the documented behavior + * instead of a crash. + * + * The failure is cached alongside the success so a box without a binary + * doesn't re-attempt (and re-throw) the resolution on every secret + * operation, and so `set` can name the underlying cause in its + * `KeychainUnavailableError`. + * + * The cache is process-lifetime by design — there is no reset seam, since + * a platform does not grow a native binary mid-run. Tests reach the + * unloadable path through `vi.resetModules()` + `vi.doMock`, which gives + * them a fresh module (and so a fresh cache) instead. + * + * A resolved import is also **shape-checked** before being accepted. The + * package is CJS, so the named exports we rely on come from interop, and + * a resolution that stopped yielding them (a default-only export in a + * future version, a bundler changing interop, a platform where the + * named-export detection fails) would land as `undefined`, making + * `new mod.AsyncEntry(...)` throw `TypeError: not a constructor` from + * inside the very `try` that implements graceful degradation. + * + * Be precise about what this buys, because it is narrower than it looks: + * it does **not** stop a bad shape from emptying the secret list. `get` + * returns `null` either way — that is its read-tolerance contract, and + * the `TypeError` lands in the same `catch` that a dead keychain does. + * What the check changes is *diagnosis*. Without it the only signal is + * `set` reporting "keyring.mod.AsyncEntry is not a constructor", an + * internal-looking message that reads like an Inspector bug; with it, + * `set` names the actual problem once, at the load boundary, in the same + * actionable 503 as every other flavor of unavailability. Detecting the + * silent-empty-list case itself would take a real round-trip against the + * unmocked package, which nothing in the suite does today. + */ +type KeyringModule = typeof import("@napi-rs/keyring"); +type KeyringLoad = + | { ok: true; mod: KeyringModule } + | { ok: false; err: unknown }; + +let keyringLoad: Promise<KeyringLoad> | undefined; + +/** + * Accept a resolved import only if it carries the two members we call. + * + * The member *access* is inside the `try` because reading a missing + * export is not always the harmless `undefined` a plain ESM namespace + * gives: a Proxy-based namespace can throw on an unknown key (vitest's + * module mocks do exactly that). Either way the answer is the same — + * unavailable — and returning it rather than throwing is what keeps the + * cached promise from ever rejecting. + */ +/** + * Marks the shape-check failure so `KeychainUnavailableError` can give + * advice that fits it. A distinct type rather than a string match on the + * message: this is our own error, so there is no reason to re-parse text + * we just wrote (the native-binding branch matches on a string only + * because that message comes from someone else's loader). + */ +export class KeyringModuleShapeError extends Error { + constructor(detail: string, options?: { cause?: unknown }) { + super(`@napi-rs/keyring loaded but ${detail}`, options); + this.name = "KeyringModuleShapeError"; + } +} + +const checkKeyringShape = (mod: KeyringModule): KeyringLoad => { + try { + if ( + typeof mod.AsyncEntry === "function" && + typeof mod.findCredentialsAsync === "function" + ) { + return { ok: true, mod }; + } + return { + ok: false, + err: new KeyringModuleShapeError( + "did not expose AsyncEntry / findCredentialsAsync", + ), + }; + } catch (err) { + // Both ways of failing the shape check are the same problem — the + // module is not the API we expect — so both carry the type that + // earns the packaging hint. Returning the raw error here instead + // would drop it back to the libsecret advice, which is what this + // whole branch exists to avoid. + return { + ok: false, + err: new KeyringModuleShapeError( + `its exports could not be read: ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, + ), + }; + } +}; + +const loadKeyring = (): Promise<KeyringLoad> => { + keyringLoad ??= import("@napi-rs/keyring").then( + checkKeyringShape, + (err: unknown): KeyringLoad => ({ ok: false, err }), + ); + return keyringLoad; +}; + export { SECRET_FIELD_OAUTH_CLIENT_SECRET, SECRET_FIELD_IDP_CLIENT_SECRET, @@ -39,22 +153,50 @@ const buildAccount = (serverId: string, field: string): string => `${serverId}:${field}`; /** - * Thrown when the OS keychain is unavailable — typically Linux without - * libsecret / gnome-keyring installed. Surfaced as a 503 by the API - * handlers so the UI can show an actionable error rather than a generic - * 500. macOS and Windows always have a working keychain, so this only - * realistically fires on minimal Linux installs. + * Thrown when the OS keychain is unavailable. Surfaced as a 503 by the + * API handlers so the UI can show an actionable error rather than a + * generic 500 — and "actionable" is the point: the causes need + * *different* fixes, so the message carries a hint chosen per cause + * (see `hintFor`). Three realistic ones: + * + * - **The keychain itself is missing** — Linux without libsecret / + * gnome-keyring. Install it. + * - **`@napi-rs/keyring` won't load** — no platform binary for this + * triple (Android/Termux, #1905) or npm's optional-deps bug dropping + * it on a supported one (npm/cli#4828). Reinstall / clear the npx + * cache; installing a keyring daemon would not help. + * - **It loads but exposes the wrong API** — a version or packaging + * mismatch (`KeyringModuleShapeError`). Also not a daemon problem. */ export class KeychainUnavailableError extends Error { constructor(cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause); super( - `OS keychain is not available. On Linux, install libsecret / gnome-keyring. ` + - `Underlying error: ${cause instanceof Error ? cause.message : String(cause)}`, + `OS keychain is not available. ${hintFor(cause, message)} Underlying error: ${message}`, ); this.name = "KeychainUnavailableError"; } } +/** + * The remediation that fits the cause. Wrong advice is worse than none — + * telling someone on Windows to install libsecret sends them down a path + * that cannot work — so every cause that has its own fix gets its own + * branch, and the libsecret line is the fallback rather than the default. + */ +const hintFor = (cause: unknown, message: string): string => { + // Our own error, so match on the type rather than re-parsing text we wrote. + if (cause instanceof KeyringModuleShapeError) { + return `The @napi-rs/keyring package loaded but does not expose the API this build expects — most likely a version or packaging mismatch; reinstall the Inspector, and report this if it persists.`; + } + // This phrasing comes from the napi-rs loader, not from us: it is what + // the package throws when the platform binary is missing. + if (message.includes("Cannot find native binding")) { + return `The @napi-rs/keyring platform package for this OS is missing or unavailable — reinstall the Inspector (for npx, clear the npx cache under your npm cache directory first).`; + } + return `On Linux, install libsecret / gnome-keyring.`; +}; + /** * Storage interface for the per-server secrets we lift off * `~/.mcp-inspector/mcp.json`. Implemented by `KeyringSecretStore` (the @@ -79,19 +221,48 @@ export interface SecretStore { * can use `=== null` rather than truthiness (an empty-string secret is * a real value and must round-trip). * - * **Availability behavior.** When the keychain is unavailable (the - * typical case is Linux without libsecret / gnome-keyring), `set` is + * **Availability behavior.** When the keychain is unavailable, `set` is * the only operation that throws `KeychainUnavailableError` — that's * the moment where data would actually be lost. `get` returns `null` * (as if no entry existed) and the destructive operations silently - * no-op (there's nothing to delete anyway). This keeps non-secret - * flows working on a stock CI runner / minimal Linux box; the user - * only hits a hard error when they actually try to save a secret. + * no-op (there's nothing to delete anyway). This keeps non-secret flows + * working on a stock CI runner / minimal Linux box / unsupported + * platform; the user only hits a hard error when they actually try to + * save a secret. + * + * "Unavailable" covers four distinct failures, all funneled into that + * one contract — the contract is only as good as its narrowest funnel, + * and each of these escaped it at some point: + * + * 1. **The package won't load at all** — no prebuilt binary for this + * platform (Android / Termux). A static top-level import made this a + * startup crash before any handling ran (#1905); `loadKeyring()` + * above defers and caches it instead. + * 2. **The package loads but exposes the wrong shape** — the named + * exports arrive via CJS interop, so a resolution that stopped + * yielding them would hand us `undefined` and fail as a `TypeError` + * swallowed by the degradation path. `loadKeyring()` shape-checks up + * front so `set` can name that cause instead of surfacing an + * "is not a constructor" message (see the note there — the check + * improves the diagnosis, it does not change what `get` returns). + * 3. **`AsyncEntry::new` throws** — it performs the platform-store setup + * (on Linux, the Secret Service connect with a keyutils fallback) and + * throws when no backend is reachable. Construction is therefore + * deliberately **inside** each method's `try`; outside it, the raw + * error escaped and 500'd every `GET /api/servers` before any secret + * was involved (#1848). + * 4. **The operation itself throws** — the original case, and the only + * one the first version of this contract actually handled. */ export class KeyringSecretStore implements SecretStore { async get(serverId: string, field: string): Promise<string | null> { - const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field)); try { + const keyring = await loadKeyring(); + if (!keyring.ok) return null; + const entry = new keyring.mod.AsyncEntry( + SERVICE_NAME, + buildAccount(serverId, field), + ); const v = await entry.getPassword(); return v ?? null; } catch { @@ -104,10 +275,21 @@ export class KeyringSecretStore implements SecretStore { } async set(serverId: string, field: string, value: string): Promise<void> { - const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field)); try { + const keyring = await loadKeyring(); + // An unloadable package is as fatal to a write as an unreachable + // keychain, and for the same reason — the value would vanish. + if (!keyring.ok) throw new KeychainUnavailableError(keyring.err); + const entry = new keyring.mod.AsyncEntry( + SERVICE_NAME, + buildAccount(serverId, field), + ); await entry.setPassword(value); } catch (err) { + // Already the typed error when the module failed to load — don't + // double-wrap it (that would bury the underlying cause one level + // deeper in the message). + if (err instanceof KeychainUnavailableError) throw err; // The only operation that hard-fails — if we can't persist the // secret, the user needs to know now rather than discover later // that their value disappeared. Routes translate this to a 503. @@ -116,24 +298,31 @@ export class KeyringSecretStore implements SecretStore { } async delete(serverId: string, field: string): Promise<void> { - const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field)); try { + const keyring = await loadKeyring(); + if (!keyring.ok) return; + const entry = new keyring.mod.AsyncEntry( + SERVICE_NAME, + buildAccount(serverId, field), + ); await entry.deleteCredential(); } catch { - // Both reasons for a throw collapse to the same desired outcome + // Every reason for a throw collapses to the same desired outcome // ("the entry isn't there anymore"): `deleteCredential` raises - // NoEntry for a missing credential, and the native binding - // raises a runtime error when the keychain itself is unavailable. - // We treat both as success — there's no value to lose either - // way, and `set` is the operation that hard-fails when the - // keychain is actually down. + // NoEntry for a missing credential, and both the constructor and + // the native binding raise a runtime error when the keychain + // itself is unavailable. We treat all of them as success — there's + // no value to lose either way, and `set` is the operation that + // hard-fails when the keychain is actually down. } } async deleteAllForServer(serverId: string): Promise<void> { let creds: Array<{ account: string; password: string }>; try { - creds = await findCredentialsAsync(SERVICE_NAME); + const keyring = await loadKeyring(); + if (!keyring.ok) return; + creds = await keyring.mod.findCredentialsAsync(SERVICE_NAME); } catch { // Same reasoning as `delete`: nothing was written, nothing to sweep. return; diff --git a/core/mcp/__tests__/fakeInspectorClient.ts b/core/mcp/__tests__/fakeInspectorClient.ts index c9fc9cdbd..00e3ed161 100644 --- a/core/mcp/__tests__/fakeInspectorClient.ts +++ b/core/mcp/__tests__/fakeInspectorClient.ts @@ -133,6 +133,10 @@ export class FakeInspectorClient return this.tasksExtensionNegotiated; } + // Attributes a failed load back to its Protocol entry (#1953). A `vi.fn` so + // tests can assert the method name and reason a failing refresh reported. + markResponseRejected = vi.fn((_method: string, _reason: string) => {}); + // Aggregate variants used by the managed state stores on refresh: drain ALL // queued pages (mimicking the SDK's all-page walk) and return the flattened // list. The `options` (incl. `cacheMode`) is recorded by the `vi.fn` so tests diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index e63b14737..b0b809f3f 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -358,6 +358,13 @@ export class InspectorClient extends InspectorClientEventTarget { private outputValidator: AjvJsonSchemaValidator | null = null; private transport: Transport | MessageTrackingTransport | null = null; private baseTransport: Transport | null = null; + // Correlation for `markResponseRejected` (#1953): the method of each + // outbound request still awaiting a response, and — once one is answered — + // the id of the most recently answered request per method. Entries are + // dropped as responses arrive, so this holds at most one id per method + // rather than growing with the session. + private outboundRequestMethods = new Map<string | number, string>(); + private lastAnsweredRequestByMethod = new Map<string, string | number>(); /** True when the cached transport was built with an OAuth authProvider attached. */ private transportHasAuthProvider = false; /** Dedupes concurrent ambient auth challenges (reason + scopes). */ @@ -472,7 +479,10 @@ export class InspectorClient extends InspectorClientEventTarget { // `McpSubscription` whose filter's `resourceSubscriptions` mirrors // `subscribedResources`; mutating the set re-lists (close old, open new), and // an unexpected `"remote"` close re-lists (reconnect-by-re-listen — the stream - // is not resumable). `null` when no URI is subscribed or on the legacy era. + // is not resumable). `null` on the legacy era, and on the modern era whenever + // the filter is empty — which is *not* the same as "no URI subscribed": the + // stream also carries the list-change opt-ins, so a tools-only server opens it + // with no subscriptions at all (#1920). private modernSubscription: McpSubscription | null = null; // Monotonic guard so a stale re-list/reconnect (whose `listen()` or `closed` // resolves after a newer refresh already started) can detect it lost the race @@ -786,6 +796,9 @@ export class InspectorClient extends InspectorClientEventTarget { private createMessageTrackingCallbacks(): MessageTrackingCallbacks { return { trackRequest: (message: JSONRPCRequest, origin: MessageOrigin) => { + if (origin === "client") { + this.outboundRequestMethods.set(message.id, message.method); + } const entry: MessageEntry = { id: crypto.randomUUID(), timestamp: new Date(), @@ -799,6 +812,19 @@ export class InspectorClient extends InspectorClientEventTarget { message: JSONRPCResultResponse | JSONRPCErrorResponse, origin: MessageOrigin, ) => { + // A response to one of OUR requests closes that id's correlation entry + // and becomes the method's most recent answer (#1953). The transport + // only tracks responses that carry an id, but the JSON-RPC types leave + // it optional for an error frame the server couldn't attribute — such a + // frame answers no specific request, so it is skipped. + const responseId = message.id; + if (origin === "server" && responseId !== undefined) { + const method = this.outboundRequestMethods.get(responseId); + this.outboundRequestMethods.delete(responseId); + if (method !== undefined) { + this.lastAnsweredRequestByMethod.set(method, responseId); + } + } const entry: MessageEntry = { id: crypto.randomUUID(), timestamp: new Date(), @@ -1558,6 +1584,17 @@ export class InspectorClient extends InspectorClientEventTarget { this.clearReceiverTasks(); this.resetSubscriptionStream(); this.cancelledTaskIds.clear(); + // Correlation data is per-session: JSON-RPC ids don't survive it, and + // MessageLogState drops its entries on disconnect, so anything left here + // could only point at an entry that no longer exists. Clearing also + // releases the ids of requests that never got a response (a timeout, a + // dropped connection) — the only entries `trackResponse` can't remove, so + // without this they accumulate across reconnects. Cleared here on the + // start-clean path rather than in `disconnect()` for the reason documented + // above: one route out (`onerror` with no `onclose`) tears down nothing + // (#1953). + this.outboundRequestMethods.clear(); + this.lastAnsweredRequestByMethod.clear(); for (const [, controller] of this.taskInputAbortControllers) { controller.abort(new Error("Connection ended")); } @@ -1899,8 +1936,6 @@ export class InspectorClient extends InspectorClientEventTarget { // capabilities and wipe tools/prompts/resources to empty on every connect. await this.fetchServerInfo(); - this.dispatchTypedEvent("connect"); - // Set initial logging level if configured and server supports it if (this.initialLoggingLevel && this.capabilities?.logging) { await this.client.setLoggingLevel( @@ -2022,6 +2057,33 @@ export class InspectorClient extends InspectorClientEventTarget { // a progress notification handler so the Protocol's _onprogress stays; timeout reset // and routing work, and we inject the caller's progressToken into dispatched events. } + + // Modern era: the handlers registered above only fire for notifications + // that reach us, and on this era every server→client notification rides + // the `subscriptions/listen` stream. Open it now when the filter says + // there is something to listen for — otherwise a list-change opt-in on a + // server with no resources would have no way in, since a subscribe click + // was the only thing that ever opened the stream (#1920). + await this.openModernListenStreamOnConnect(); + + // Last, so the notification channel is established (or its retry armed) + // before any consumer acts on the connection. The managed list states + // start their initial `refresh()` from this event, so dispatching earlier + // would let `tools/list` go out ahead of `subscriptions/listen` — and a + // list the server changes in that window would notify nobody, leaving the + // UI stale with no way to notice (#1920). The cost is one listen + // round-trip added to connect on the modern era. + // + // …which is also why the announcement is conditional. Every await above + // is a window for a `disconnect()` or a transport `onclose`/`onerror` to + // overtake this connect, and the listen round-trip widened it. Announcing + // then would restart every managed list refresh against a session being + // torn down or already dead. `disconnecting` covers the teardown that has + // claimed ownership but is still awaiting `client.close()` — the status is + // whatever it was until that block finishes. + if (this.status === "connected" && !this.disconnecting) { + this.dispatchTypedEvent("connect"); + } } catch (error) { if (!isConnectAuthRecoveryError(error)) { this.status = "error"; @@ -3080,8 +3142,17 @@ export class InspectorClient extends InspectorClientEventTarget { // reflect the current wire truth. Accepted for a debugging tool where the // list is small and correctness of "why did this tool vanish" matters more // than the extra request; it's a no-op (no round trip) on legacy/stdio. - // Kept best-effort: an error here must never fail the tools list itself. - await this.refreshExcludedTools(options?.metadata).catch(() => {}); + // Kept best-effort: an error here must never fail the tools list itself — + // but it is logged rather than dropped on the floor, so a failing + // excluded-tools walk is diagnosable instead of silently leaving the + // "Excluded (SEP-2243)" section empty and looking like a clean server + // (#1953). + await this.refreshExcludedTools(options?.metadata).catch((err: unknown) => { + this.logger.warn( + { err }, + "Excluded-tools walk failed; the SEP-2243 excluded list may be incomplete", + ); + }); return { tools: [...response.tools] }; } @@ -3095,6 +3166,27 @@ export class InspectorClient extends InspectorClientEventTarget { return this.isModernEra() && this.getServerType() !== "stdio"; } + /** + * Mark the response that most recently answered `method` as rejected by the + * client, so its Protocol entry shows why instead of rendering as a clean + * success (#1953). + * + * The SDK gives no request id with a decode failure — `SdkError` carries the + * method and nothing else — so the id is recovered by correlation: the last + * response received for that method. That is exact rather than approximate, + * because the SDK rejects synchronously while decoding the response (inside + * the transport's `onmessage`) and the caller's `catch` runs in the very next + * microtask. Delivering another response for the same method in that window + * would take a macrotask (a socket read), which cannot interleave there. + * + * A no-op when nothing has answered `method` this session. + */ + markResponseRejected(method: string, reason: string): void { + const id = this.lastAnsweredRequestByMethod.get(method); + if (id === undefined) return; + this.dispatchTypedEvent("responseRejected", { id, reason }); + } + /** The current SEP-2243 excluded-tools set (empty on legacy/stdio). */ getExcludedTools(): ExcludedTool[] { return this.excludedTools; @@ -4829,9 +4921,13 @@ export class InspectorClient extends InspectorClientEventTarget { * opted-in notification type (SEP §7.4). */ private buildSubscriptionFilter(): SubscriptionFilter { - const filter: SubscriptionFilter = { - resourceSubscriptions: Array.from(this.subscribedResources), - }; + const filter: SubscriptionFilter = {}; + // Omitted rather than sent empty: a listChanged-only stream (#1920) is not + // subscribing to any resource, and `[]` would say it asked for none of a set + // it is participating in. + if (this.subscribedResources.size > 0) { + filter.resourceSubscriptions = Array.from(this.subscribedResources); + } if ( this.listChangedNotifications.tools && this.capabilities?.tools?.listChanged @@ -4853,6 +4949,82 @@ export class InspectorClient extends InspectorClientEventTarget { return filter; } + /** + * Whether the modern listen stream should be open: the built filter carries + * something to listen for (#1920). Before #1920 this was "at least one URI is + * subscribed", which made the stream unreachable on a server with no resources + * — a tools-only server advertising `tools.listChanged` had no way to open it, + * so `notifications/tools/list_changed` could never arrive. The filter already + * modelled the list-change opt-ins; only the trigger was narrower than the + * filter it built. This matches the SDK's own `ClientOptions.listChanged` + * auto-open, which opens whenever the effective (config ∩ capability) + * intersection is non-empty. + * + * Note this is deliberately *not* the same predicate as the stream state's + * `active` — see `modernStreamActive()`. + */ + private wantsModernStream(): boolean { + const filter = this.buildSubscriptionFilter(); + return ( + (filter.resourceSubscriptions?.length ?? 0) > 0 || + filter.toolsListChanged === true || + filter.resourcesListChanged === true || + filter.promptsListChanged === true + ); + } + + /** + * Whether the stream state reports `active` — i.e. whether the *Subscriptions* + * UI has a stream to describe. That is a narrower question than + * `wantsModernStream()`: `ResourceSubscriptionStreamState` drives the + * Subscriptions section's badge, so a stream open purely for list-change + * notifications (no subscribed URI) has nothing to report there and stays + * `active: false` (#1920). Keeping the two apart also preserves the invariant + * the rest of this file is written against — an empty subscribed set is never + * announced alongside an `active` stream. + */ + private modernStreamActive(): boolean { + return this.subscribedResources.size > 0; + } + + /** + * Open the modern listen stream at the end of a successful connect, when the + * filter is non-empty (#1920). Only the list-change opt-ins can make it + * non-empty here — the subscribed set is emptied by `resetSessionState()` on + * the way in — so this is exactly the "server advertises a listChanged the + * Inspector wants" case that had no trigger before. + * + * A failure is not allowed to fail the connect: the handshake succeeded and + * every request-scoped feature works without this stream. It is reported the + * way a lost stream is — hand it to the reconnect machinery, which retries + * with backoff and settles on `"ended"` past the cap. + * + * Gated on the same generation test as `subscribeToResource` — see the long + * comment there. The `connect` event has deliberately *not* been dispatched + * yet (that is the point of running here), so a list-state consumer is not the + * risk; what is, is anything else that bumps the generation while this + * `listen()` is in flight. `statusChange` has already fired, and any + * concurrent call on this instance qualifies: a `subscribeToResource` from a + * caller restoring subscriptions, or a `disconnect()` — whose + * `resetSubscriptionStream` bumps the generation too, making a reconcile here + * arm a reconnect for a session that is already gone. + */ + private async openModernListenStreamOnConnect(): Promise<void> { + if (!this.isModernEra() || !this.wantsModernStream()) return; + const generationBefore = this.modernListenGeneration; + try { + await this.refreshModernSubscription(); + } catch (error) { + this.logger.error( + { error }, + "Failed to open the modern subscriptions/listen stream on connect", + ); + if (this.modernListenGeneration === generationBefore + 1) { + this.reconcileModernStreamStateAfterFailedRefresh(); + } + } + } + /** Cancel a pending reconnect re-listen, if any (#1630). */ private clearModernReconnectTimer(): void { if (this.modernReconnectTimer !== undefined) { @@ -4863,9 +5035,10 @@ export class InspectorClient extends InspectorClientEventTarget { /** * (Re-)establish the modern `subscriptions/listen` stream to match the current - * `subscribedResources` set (#1630). Because the stream is not resumable, - * every filter change re-lists: the existing stream is closed and a fresh - * `listen()` opened. With no subscribed URIs the stream is left closed. + * filter (#1630). Because the stream is not resumable, every filter change + * re-lists: the existing stream is closed and a fresh `listen()` opened. With + * an empty filter — no subscribed URIs *and* no enabled list-change opt-in the + * server advertises — the stream is left closed (#1920). * * `modernListenGeneration` guards against races — if a newer refresh starts * while this one awaits its acknowledgement, the just-opened stream is @@ -4891,8 +5064,8 @@ export class InspectorClient extends InspectorClientEventTarget { await closeSubscriptionBestEffort(previous); } - // Nothing subscribed → keep the stream closed. - if (this.subscribedResources.size === 0) { + // Nothing to listen for → keep the stream closed. + if (!this.wantsModernStream()) { this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); return; } @@ -4914,7 +5087,7 @@ export class InspectorClient extends InspectorClientEventTarget { // starts fresh next time (#1630). this.modernReconnectAttempts = 0; this.setModernStreamState({ - active: true, + active: this.modernStreamActive(), status: "acknowledged", honoredUris: subscription.honoredFilter.resourceSubscriptions ?? [], }); @@ -4960,7 +5133,7 @@ export class InspectorClient extends InspectorClientEventTarget { const shouldReconnect = reason === "remote" && !isTerminalStatus(this.status) && - this.subscribedResources.size > 0; + this.wantsModernStream(); if (!shouldReconnect) { // "stream gone but subscriptions remain" renders the same whether we gave // up after failed reconnects or the server closed it gracefully: keep the @@ -4969,7 +5142,7 @@ export class InspectorClient extends InspectorClientEventTarget { // until the next `connect()` calls `resetSubscriptionStream`, which moves // them together — so "active with an empty set" is never observable.) this.setModernStreamState({ - active: this.subscribedResources.size > 0, + active: this.modernStreamActive(), status: "ended", honoredUris: [], }); @@ -4988,10 +5161,11 @@ export class InspectorClient extends InspectorClientEventTarget { * refresh owns the state as well as the filter — so both call sites gate on * the generation first. * - * The empty case is the ordinary one: nothing subscribed, no stream, inactive. - * The non-empty one exists because a failed re-listen leaves - * `modernSubscription` null with URIs still subscribed, and nothing else will - * notice: the reconnect machinery is reachable only from a stream that closed + * The two branches are the empty and non-empty *filter* (#1920) — which is + * "nothing subscribed" only when no list-change opt-in is live. The empty case + * is the ordinary one: nothing to listen for, no stream, inactive. The + * non-empty one exists because a failed re-listen leaves `modernSubscription` + * null with the filter still wanting a stream, and nothing else will notice: the reconnect machinery is reachable only from a stream that closed * or a reconnect that failed, and neither happened here. Left alone, the state * keeps whatever the last success (or the optimistic `"connecting"`) wrote — a * badge that will never change over subscriptions the server may never have @@ -4999,17 +5173,17 @@ export class InspectorClient extends InspectorClientEventTarget { * Subscribe early-returns on the URI already being in the set). * * So it reconnects rather than settling for an honest-but-dead `"ended"`: - * every other route to "stream gone, URIs live" either expects the close or + * every other route to "stream gone, filter live" either expects the close or * has exhausted the retry cap, and this is the one that has made no attempt * at all. `scheduleModernReconnect` fits as-is — a user-initiated refresh * already reset `modernReconnectAttempts`, so it starts at the base delay; the - * timer bails on a terminal status or an emptied set; and past the cap + * timer bails on a terminal status or an emptied filter; and past the cap * `onModernReconnectFailed` lands on the same `"ended"` badge. The state * therefore becomes true or ends after a real attempt. The caller still sees * its error either way — the retry is about the subscriptions, not the call. */ private reconcileModernStreamStateAfterFailedRefresh(): void { - if (this.subscribedResources.size === 0) { + if (!this.wantsModernStream()) { this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); return; } @@ -5024,7 +5198,7 @@ export class InspectorClient extends InspectorClientEventTarget { */ private scheduleModernReconnect(): void { this.setModernStreamState({ - active: true, + active: this.modernStreamActive(), status: "reconnecting", honoredUris: [], }); @@ -5037,10 +5211,7 @@ export class InspectorClient extends InspectorClientEventTarget { this.modernReconnectTimer = undefined; // Disconnect/unsubscribe may have raced the timer — bail if the reconnect // is no longer wanted. - if ( - isTerminalStatus(this.status) || - this.subscribedResources.size === 0 - ) { + if (isTerminalStatus(this.status) || !this.wantsModernStream()) { return; } this.refreshModernSubscription(true).catch(() => @@ -5059,10 +5230,10 @@ export class InspectorClient extends InspectorClientEventTarget { if ( this.modernReconnectAttempts > MODERN_RECONNECT_MAX_ATTEMPTS || isTerminalStatus(this.status) || - this.subscribedResources.size === 0 + !this.wantsModernStream() ) { this.setModernStreamState({ - active: this.subscribedResources.size > 0, + active: this.modernStreamActive(), status: "ended", honoredUris: [], }); diff --git a/core/mcp/inspectorClientEventTarget.ts b/core/mcp/inspectorClientEventTarget.ts index a4ccafe0b..d4edee160 100644 --- a/core/mcp/inspectorClientEventTarget.ts +++ b/core/mcp/inspectorClientEventTarget.ts @@ -71,6 +71,15 @@ export interface InspectorClientEventMap { /** `server/discover` result on a probed/pinned connect; undefined on legacy. */ discoverResultChange: DiscoverResult | undefined; message: MessageEntry; + /** + * A response the client REJECTED after it was logged — the server answered, + * but the SDK's era codec refused the result (e.g. a 2026-07-28 `tools/list` + * missing `ttlMs`/`cacheScope`). The wire frame is a valid JSON-RPC result, + * so the Protocol entry would otherwise render as a clean success; this + * carries the reason so it can be marked instead (#1953). `id` is the + * JSON-RPC id of the rejected response. + */ + responseRejected: { id: string | number; reason: string }; stderrLog: StderrLogEntry; fetchRequest: FetchRequestEntry; /** Fired when an in-flight fetch's response body is read asynchronously. */ diff --git a/core/mcp/inspectorClientProtocol.ts b/core/mcp/inspectorClientProtocol.ts index 3bed52235..6a5322eaf 100644 --- a/core/mcp/inspectorClientProtocol.ts +++ b/core/mcp/inspectorClientProtocol.ts @@ -105,6 +105,15 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget { * and the modern task store's poll-based refresh. */ isTasksExtensionNegotiated(): boolean; + /** + * Mark the response that most recently answered `method` as rejected by the + * client, so its Protocol entry shows the reason instead of rendering as a + * clean success (#1953). Optional so existing test doubles satisfy the + * interface without implementing it; see `InspectorClient` for how the id is + * correlated and why that correlation is exact. + */ + markResponseRejected?(method: string, reason: string): void; + // Aggregate (all-page) list methods used by the managed state stores on // refresh. Unlike the single-page methods above, these route through the // SDK's cache-aware high-level verbs so `cacheMode` ('use' | 'refresh' | diff --git a/core/mcp/remote/node/remote-session.ts b/core/mcp/remote/node/remote-session.ts index 34a2db098..fc7c7654a 100644 --- a/core/mcp/remote/node/remote-session.ts +++ b/core/mcp/remote/node/remote-session.ts @@ -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 diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 4bbc518a0..9d7655482 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -78,6 +78,27 @@ import { formatClientConfigLoadError } from "../../../client/config-parse.js"; import { envSecretField } from "../../../auth/secret-fields.js"; import { ZodError } from "zod"; +/** + * Written to every SSE stream the instant it opens, before anything else. + * + * Firefox does not hand a streaming `fetch()` response to JS until the first + * *body* byte arrives; Chromium resolves the promise as soon as the headers + * do. Both SSE endpoints here flush headers immediately and then stay silent + * until there is something to report, which deadlocks Firefox on + * `/api/mcp/events`: `RemoteClientTransport.openEventStream()` awaits that + * fetch *before* the MCP client sends `initialize`, so no `initialize` → no + * event to report → no body byte → the fetch never resolves → the web UI + * hangs on "Connecting…" forever with no error anywhere (#1858). + * + * A `:` comment line is inert per the SSE spec — conforming parsers ignore it + * — so priming with one unblocks the read without inventing a wire event. + * + * `X-Content-Type-Options: nosniff` does **not** fix this. Verified against + * Firefox 153: with the header and no body byte, the fetch still never + * resolves. The delay is not MIME sniffing. + */ +const SSE_PRIMING_COMMENT = ":\n\n"; + /** * Shape of the initial config returned by GET /api/config (defaults for client). */ @@ -728,7 +749,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 +766,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; @@ -785,6 +812,7 @@ export function createRemoteApp( } }); + // Prime every SSE stream the instant it opens — see SSE_PRIMING_COMMENT. app.get("/api/mcp/events", async (c) => { const sessionId = c.req.query("sessionId"); if (!sessionId) { @@ -818,6 +846,11 @@ export function createRemoteApp( return; } + // Prime only after the consumer is registered, so nothing observable + // to the client happens before this stream can actually report + // events. See SSE_PRIMING_COMMENT. + await stream.write(SSE_PRIMING_COMMENT); + stream.onAbort(() => { // Client disconnected - clear event consumer const shouldCleanup = session.clearEventConsumer(); @@ -2389,6 +2422,16 @@ export function createRemoteApp( ensureWatcher(); } + // Prime the stream so the client's fetch() actually resolves — on + // Firefox it otherwise stays pending until the first real change + // event, which for an unedited `mcp.json` is never. See + // SSE_PRIMING_COMMENT. Deliberately *after* the subscriber is + // registered and the watcher started: callers treat the arrival of + // this stream's first bytes as proof they are subscribed, so priming + // first would hand out that proof across an `await`, before the + // watcher exists, and drop an edit made in the gap. + await stream.write(SSE_PRIMING_COMMENT); + stream.onAbort(() => { serverEventSubscribers.delete(send); void maybeStopWatcher(); diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts index 03c924611..a9dfed782 100644 --- a/core/mcp/remote/remoteClientTransport.ts +++ b/core/mcp/remote/remoteClientTransport.ts @@ -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`, { diff --git a/core/mcp/remote/types.ts b/core/mcp/remote/types.ts index 23bb6aa98..9e948401d 100644 --- a/core/mcp/remote/types.ts +++ b/core/mcp/remote/types.ts @@ -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 = diff --git a/core/mcp/state/managedListState.ts b/core/mcp/state/managedListState.ts index 4f6160e15..ea5128b55 100644 --- a/core/mcp/state/managedListState.ts +++ b/core/mcp/state/managedListState.ts @@ -17,6 +17,7 @@ import type { CacheMode, ServerCapabilities, } from "@modelcontextprotocol/client"; +import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; import { isTerminalStatus } from "../types.js"; import { TypedEventTarget } from "../typedEventTarget.js"; @@ -29,12 +30,61 @@ import { TypedEventTarget } from "../typedEventTarget.js"; */ export const DEFAULT_LIST_CHANGED_DEBOUNCE_MS = 250; -/** Every managed-list event map carries the list-changed indicator event. */ +/** + * Whether a failed fetch is the CLIENT refusing a response it received, rather + * than the request never producing one. + * + * This gates `markResponseRejected`, and the distinction is load-bearing. That + * correlation recovers the request id as "the last response received for this + * method", which is only the failing exchange when a response actually just + * arrived and was refused while decoding. A transport drop, a timeout, or an + * aborted request produces no response frame at all — the last-answered id then + * still points at some EARLIER, successful call, and marking it would stamp + * "Rejected by the Inspector" onto an exchange that succeeded. That is the very + * class of lie this issue exists to remove, so it must not be traded for + * another one. + * + * A server-sent JSON-RPC error is excluded for a different reason: it is a real + * response, so the id would be right, but the failure is the server's. Its + * entry already renders as an error from the error frame itself, and blaming + * the Inspector for it would misattribute the cause. + * + * `SdkErrorCode.InvalidResult` is exactly "a result arrived and failed + * validation for the negotiated era"; `UnsupportedResultType` is its sibling + * for a `resultType` the codec has no handling for. Both are decisions the + * client made about a frame in hand. + */ +function isClientDecodeRejection(err: unknown): boolean { + return ( + SdkError.isInstance(err) && + (err.code === SdkErrorCode.InvalidResult || + err.code === SdkErrorCode.UnsupportedResultType) + ); +} + +/** + * Every managed-list event map carries the list-changed indicator event and the + * last-fetch error (#1953). + */ export interface ManagedListEventMap { + /** + * Fires when the "list changed since last refresh" flag flips. True when + * this list's `list_changed` notification arrives (auto-refresh off), false + * once the user refreshes or the connection drops. Drives the sidebar + * list-changed indicator (#1402). + */ listChangedChange: boolean; + /** The last fetch's failure, or `null` once a fetch succeeds. */ + errorChange: Error | null; } export interface ManagedListConfig<T, M extends ManagedListEventMap> { + /** + * The JSON-RPC method this list pages (e.g. "tools/list"). Used to attribute + * a failed load back to its Protocol entry — see + * `InspectorClientProtocol.markResponseRejected` (#1953). + */ + listMethod: string; /** The `*Change` event this manager dispatches (e.g. "toolsChange"). */ changeEvent: keyof M; /** The client notification that signals the list changed. */ @@ -81,6 +131,11 @@ export abstract class ManagedListState< private unsubscribe: (() => void) | null = null; private _metadata: Record<string, string> | undefined = undefined; private listChanged = false; + // The last fetch's failure, kept as observable state so a load that fails + // (a transport error, or a result the SDK codec rejects as invalid) is + // rendered by the list panel instead of vanishing into an unhandled + // rejection — which read as "this server has no tools" (#1953). + private error: Error | null = null; private readonly config: ManagedListConfig<T, M>; // Debounce a burst of `list_changed` notifications into a single // refresh (or one indicator light) once it settles. @@ -112,7 +167,11 @@ export abstract class ManagedListState< ) { return; } - void this.refresh(); + // The connect-time load has no caller to await it, so its rejection is + // caught here rather than left to become an unhandled rejection. This is + // not a swallow: `refresh` has already recorded the failure via + // `setError`, and the list panel renders it (#1953). + void this.refresh().catch(() => {}); }; const onListChanged = (): void => { // Debounce: collapse a burst of notifications into one settled action @@ -133,6 +192,9 @@ export abstract class ManagedListState< this.items = []; this.dispatchChange(); this.setListChanged(false); + // A disconnect ends the session the error belonged to — a stale + // "couldn't load tools" must not outlive it into the next connect. + this.setError(null); } }; this.client.addEventListener("connect", onConnect); @@ -187,8 +249,10 @@ export abstract class ManagedListState< if (!skipAggregate && settings?.autoRefreshOnListChanged) { // A `list_changed` means the prior list is stale, so bypass any // cached entry (`cacheMode: "refresh"`) and re-store the fresh - // aggregate. - await this.refresh(undefined, "refresh"); + // aggregate. Like the connect-time load this has no caller to await + // it, so a failure is caught here — already recorded by `setError` + // and rendered by the panel (#1953). + await this.refresh(undefined, "refresh").catch(() => {}); } else if (this.config.supportsIndicator) { this.setListChanged(true); } @@ -236,6 +300,20 @@ export abstract class ManagedListState< this.emit("listChangedChange", value); } + /** The last fetch's failure, or `null` when the last fetch succeeded. */ + getError(): Error | null { + return this.error; + } + + // Compared by identity rather than message: two distinct failures with the + // same text are still two events, and a re-render on a repeat failure is + // cheap next to silently coalescing them. + private setError(value: Error | null): void { + if (this.error === value) return; + this.error = value; + this.emit("errorChange", value); + } + setMetadata(metadata?: Record<string, string>): void { this._metadata = metadata; } @@ -245,12 +323,37 @@ export abstract class ManagedListState< * this fetch: `undefined` (the connect-time load) uses the default `'use'`; * a user-initiated or auto refresh passes `'refresh'` to force a * cache-bypassing round trip and re-store the fresh aggregate. + * + * A failure is recorded as observable state (`getError`) AND re-thrown: the + * state drives the panel's error rendering, while the rejection is what the + * caller's auth-recovery wrapper keys off to detect a 401 and start a + * re-authorization. Callers with nobody to await them (the connect-time load, + * the `list_changed` auto-refresh) catch it explicitly (#1953). */ async refresh( metadata?: Record<string, string>, cacheMode?: CacheMode, ): Promise<T[]> { - const next = await this.fetchItems(metadata, cacheMode); + let next: T[] | null; + try { + next = await this.fetchItems(metadata, cacheMode); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + this.setError(error); + // Attribute the failure to the response it came from, so the Protocol + // entry stops rendering a rejected result as a clean success (#1953). + // Must happen in this catch, while the correlation window the client + // documents is still valid — and ONLY for a decode rejection, see + // `isClientDecodeRejection`. + if (isClientDecodeRejection(err)) { + this.client?.markResponseRejected?.( + this.config.listMethod, + error.message, + ); + } + throw err; + } + this.setError(null); // `null` means not connected — leave the current list untouched. if (next === null) return this.getItems(); this.applyItems(next); diff --git a/core/mcp/state/managedPromptsState.ts b/core/mcp/state/managedPromptsState.ts index a12fcd4af..93a106ae5 100644 --- a/core/mcp/state/managedPromptsState.ts +++ b/core/mcp/state/managedPromptsState.ts @@ -8,17 +8,11 @@ import type { Prompt } from "@modelcontextprotocol/client"; import { ManagedListState, DEFAULT_LIST_CHANGED_DEBOUNCE_MS, + type ManagedListEventMap, } from "./managedListState.js"; -export interface ManagedPromptsStateEventMap { +export interface ManagedPromptsStateEventMap extends ManagedListEventMap { promptsChange: Prompt[]; - /** - * Fires when the "list changed since last refresh" flag flips. True when a - * `prompts/list_changed` arrives (auto-refresh off), false once the user - * refreshes or the connection drops. Drives the sidebar list-changed - * indicator (#1402). - */ - listChangedChange: boolean; } export class ManagedPromptsState extends ManagedListState< @@ -30,6 +24,7 @@ export class ManagedPromptsState extends ManagedListState< debounceMs = DEFAULT_LIST_CHANGED_DEBOUNCE_MS, ) { super(client, { + listMethod: "prompts/list", changeEvent: "promptsChange", listChangedEvent: "promptsListChanged", capabilityKey: "prompts", diff --git a/core/mcp/state/managedResourceTemplatesState.ts b/core/mcp/state/managedResourceTemplatesState.ts index aceedd4f2..fe5f44f23 100644 --- a/core/mcp/state/managedResourceTemplatesState.ts +++ b/core/mcp/state/managedResourceTemplatesState.ts @@ -15,15 +15,11 @@ import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprot import { ManagedListState, DEFAULT_LIST_CHANGED_DEBOUNCE_MS, + type ManagedListEventMap, } from "./managedListState.js"; -export interface ManagedResourceTemplatesStateEventMap { +export interface ManagedResourceTemplatesStateEventMap extends ManagedListEventMap { resourceTemplatesChange: ResourceTemplate[]; - /** - * Carried only to satisfy the ManagedListState base; templates have no - * indicator, so this never fires. - */ - listChangedChange: boolean; } export class ManagedResourceTemplatesState extends ManagedListState< @@ -35,6 +31,7 @@ export class ManagedResourceTemplatesState extends ManagedListState< debounceMs = DEFAULT_LIST_CHANGED_DEBOUNCE_MS, ) { super(client, { + listMethod: "resources/templates/list", changeEvent: "resourceTemplatesChange", listChangedEvent: "resourceTemplatesListChanged", // Templates are gated on the broader `resources` capability. diff --git a/core/mcp/state/managedResourcesState.ts b/core/mcp/state/managedResourcesState.ts index f00076e48..1d693bd8d 100644 --- a/core/mcp/state/managedResourcesState.ts +++ b/core/mcp/state/managedResourcesState.ts @@ -8,17 +8,11 @@ import type { Resource } from "@modelcontextprotocol/client"; import { ManagedListState, DEFAULT_LIST_CHANGED_DEBOUNCE_MS, + type ManagedListEventMap, } from "./managedListState.js"; -export interface ManagedResourcesStateEventMap { +export interface ManagedResourcesStateEventMap extends ManagedListEventMap { resourcesChange: Resource[]; - /** - * Fires when the "list changed since last refresh" flag flips. True when a - * `resources/list_changed` arrives (auto-refresh off), false once the user - * refreshes or the connection drops. Drives the list-changed indicator - * (#1402). - */ - listChangedChange: boolean; } export class ManagedResourcesState extends ManagedListState< @@ -30,6 +24,7 @@ export class ManagedResourcesState extends ManagedListState< debounceMs = DEFAULT_LIST_CHANGED_DEBOUNCE_MS, ) { super(client, { + listMethod: "resources/list", changeEvent: "resourcesChange", listChangedEvent: "resourcesListChanged", capabilityKey: "resources", diff --git a/core/mcp/state/managedToolsState.ts b/core/mcp/state/managedToolsState.ts index 03a87d599..10ed14bda 100644 --- a/core/mcp/state/managedToolsState.ts +++ b/core/mcp/state/managedToolsState.ts @@ -8,17 +8,11 @@ import type { Tool } from "@modelcontextprotocol/client"; import { ManagedListState, DEFAULT_LIST_CHANGED_DEBOUNCE_MS, + type ManagedListEventMap, } from "./managedListState.js"; -export interface ManagedToolsStateEventMap { +export interface ManagedToolsStateEventMap extends ManagedListEventMap { toolsChange: Tool[]; - /** - * Fires when the "list changed since last refresh" flag flips. True when a - * `tools/list_changed` arrives (auto-refresh off), false once the user - * refreshes or the connection drops. Drives the sidebar list-changed - * indicator (#1402). - */ - listChangedChange: boolean; } export class ManagedToolsState extends ManagedListState< @@ -30,6 +24,7 @@ export class ManagedToolsState extends ManagedListState< debounceMs = DEFAULT_LIST_CHANGED_DEBOUNCE_MS, ) { super(client, { + listMethod: "tools/list", changeEvent: "toolsChange", listChangedEvent: "toolsListChanged", capabilityKey: "tools", diff --git a/core/mcp/state/messageLogState.ts b/core/mcp/state/messageLogState.ts index 9355741e5..02a27224e 100644 --- a/core/mcp/state/messageLogState.ts +++ b/core/mcp/state/messageLogState.ts @@ -98,6 +98,31 @@ export class MessageLogState extends TypedEventTarget<MessageLogStateEventMap> { pushEntry(entry); }; + // A response the client rejected after the fact (the wire frame was valid + // JSON-RPC, but the SDK codec refused the result). Annotate the entry that + // carries that JSON-RPC id so it stops rendering as a clean success — the + // request entry when the response was folded into it, otherwise the + // standalone response entry. Searched newest-first: ids are unique within + // a session, and the failing exchange is by construction a recent one. + const onResponseRejected = ( + event: TypedEventGeneric<InspectorClientEventMap, "responseRejected">, + ): void => { + const { id, reason } = event.detail; + for (let i = this.messages.length - 1; i >= 0; i--) { + const entry = this.messages[i]!; + const entryId = (entry.message as { id?: string | number }).id; + if (entryId !== id) continue; + if (entry.direction === "notification") continue; + // A request entry with no response yet isn't the one being rejected — + // keep looking for the standalone response frame. + if (entry.direction === "request" && !entry.response) continue; + entry.clientError = reason; + this.dispatchTypedEvent("message", entry); + this.dispatchTypedEvent("messagesChange", this.getMessages()); + return; + } + }; + const onStatusChange = (): void => { if (isTerminalStatus(this.client?.getStatus())) { this.messages = []; @@ -106,10 +131,12 @@ export class MessageLogState extends TypedEventTarget<MessageLogStateEventMap> { } }; this.client.addEventListener("message", onMessage); + this.client.addEventListener("responseRejected", onResponseRejected); this.client.addEventListener("statusChange", onStatusChange); this.unsubscribe = () => { if (this.client) { this.client.removeEventListener("message", onMessage); + this.client.removeEventListener("responseRejected", onResponseRejected); this.client.removeEventListener("statusChange", onStatusChange); } this.client = null; diff --git a/core/mcp/types.ts b/core/mcp/types.ts index b95c65a73..b89f117b2 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -285,6 +285,14 @@ export interface MessageEntry { | JSONRPCErrorResponse; response?: JSONRPCResultResponse | JSONRPCErrorResponse; duration?: number; // Time between request and response in ms + /** + * Why the CLIENT rejected an otherwise well-formed response — e.g. the SDK's + * era codec refusing a 2026-07-28 `tools/list` result that omits + * `ttlMs`/`cacheScope`. Distinct from a JSON-RPC `error` response: the server + * answered successfully and the wire frame is valid, so without this the + * entry renders as a clean success even though the call failed (#1953). + */ + clientError?: string; } /** Method name for any MessageEntry traffic, plus synthetic "response" for result/error entries. */ @@ -428,9 +436,14 @@ export type ResourceSubscriptionStreamStatus = * persistent stream, so `active` is `false` and the UI surfaces no stream chrome. * On the modern era all subscriptions are a filter over one long-lived * `subscriptions/listen` stream; `active` is `true` whenever that stream is being - * managed (i.e. at least one URI is subscribed), and `honoredUris` is the subset - * of requested URIs the server acknowledged in its `honoredFilter` (may be a - * strict subset — a server is allowed to decline some). + * managed *for resource subscriptions* (i.e. at least one URI is subscribed), and + * `honoredUris` is the subset of requested URIs the server acknowledged in its + * `honoredFilter` (may be a strict subset — a server is allowed to decline some). + * + * `active: false` does not imply no stream: the same stream also carries the + * list-change opt-ins, so it can be open with no subscribed URI at all (#1920). + * This state describes the Subscriptions section, which has nothing to show for + * such a stream. */ export interface ResourceSubscriptionStreamState { active: boolean; diff --git a/core/react/useManagedListError.ts b/core/react/useManagedListError.ts new file mode 100644 index 000000000..64438e523 --- /dev/null +++ b/core/react/useManagedListError.ts @@ -0,0 +1,68 @@ +import { useCallback, useSyncExternalStore } from "react"; +import type { ManagedListEventMap } from "../mcp/state/managedListState.js"; +import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; + +/** + * The slice of a managed list state this hook needs. Declared structurally + * rather than as `ManagedListState<T, M>` so the four list hooks can share it + * without threading their item type through — the error is the same shape for + * all of them. + */ +export interface ManagedListErrorSource { + getError(): Error | null; + addEventListener( + type: "errorChange", + listener: ( + event: TypedEventGeneric<ManagedListEventMap, "errorChange">, + ) => void, + ): void; + removeEventListener( + type: "errorChange", + listener: ( + event: TypedEventGeneric<ManagedListEventMap, "errorChange">, + ) => void, + ): void; +} + +/** + * Subscribe to a managed list state's last-fetch error (#1953). + * + * Shared by the four `useManaged*` hooks so a list load that fails — including + * the connect-time one, which has no caller to await it — reaches the UI + * instead of only the console. `null` means the last fetch succeeded. + * + * Built on `useSyncExternalStore` rather than the `useState` + `useEffect` + * subscribe pattern the sibling hooks use. Re-syncing state from the `state` + * prop inside an effect would render one frame carrying the PREVIOUS store's + * error after `state` changes (switching servers) before the effect corrects + * it — the "don't derive state from props in an effect" rule in AGENTS.md. + * `useSyncExternalStore` has no such window: the snapshot is read during + * render, so a store swap is reflected in the same frame, and it also closes + * the gap where an error recorded between render and subscribe would be missed. + * + * The snapshot must be referentially stable across reads that mean "no change", + * which it is: it returns the stored `Error` instance itself (or `null`), never + * a fresh object. + */ +export function useManagedListError( + state: ManagedListErrorSource | null, +): Error | null { + const subscribe = useCallback( + (onStoreChange: () => void) => { + if (!state) return () => {}; + const listener = () => onStoreChange(); + state.addEventListener("errorChange", listener); + return () => { + state.removeEventListener("errorChange", listener); + }; + }, + [state], + ); + + const getSnapshot = useCallback(() => state?.getError() ?? null, [state]); + + // Server snapshot: same read. The stores are browser/Node runtime objects + // with no SSR path, and passing the same getter keeps hydration consistent + // rather than throwing on a server render. + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/core/react/useManagedPrompts.ts b/core/react/useManagedPrompts.ts index db9b94e19..e0de0bb13 100644 --- a/core/react/useManagedPrompts.ts +++ b/core/react/useManagedPrompts.ts @@ -6,8 +6,15 @@ import type { } from "../mcp/state/managedPromptsState.js"; import type { Prompt } from "@modelcontextprotocol/client"; import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import { useManagedListError } from "./useManagedListError.js"; export interface UseManagedPromptsResult { + /** + * The last fetch's failure (transport error, or a result the SDK codec + * rejected), or `null` when it succeeded. Includes the connect-time load, + * whose failure has no caller to surface it (#1953). + */ + error: Error | null; prompts: Prompt[]; /** True when a `prompts/list_changed` arrived since the last user refresh. */ listChanged: boolean; @@ -65,6 +72,8 @@ export function useManagedPrompts( }; }, [managedPromptsState]); + const error = useManagedListError(managedPromptsState); + const refresh = useCallback(async (): Promise<Prompt[]> => { if (!managedPromptsState || !client) return []; // A user-initiated refresh acknowledges the change — clear the indicator @@ -85,5 +94,5 @@ export function useManagedPrompts( managedPromptsState?.clearListChanged(); }, [managedPromptsState]); - return { prompts, listChanged, refresh, clearListChanged }; + return { prompts, error, listChanged, refresh, clearListChanged }; } diff --git a/core/react/useManagedResourceTemplates.ts b/core/react/useManagedResourceTemplates.ts index 3851769e8..40a6b994f 100644 --- a/core/react/useManagedResourceTemplates.ts +++ b/core/react/useManagedResourceTemplates.ts @@ -6,8 +6,15 @@ import type { } from "../mcp/state/managedResourceTemplatesState.js"; import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client"; import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import { useManagedListError } from "./useManagedListError.js"; export interface UseManagedResourceTemplatesResult { + /** + * The last fetch's failure (transport error, or a result the SDK codec + * rejected), or `null` when it succeeded. Includes the connect-time load, + * whose failure has no caller to surface it (#1953). + */ + error: Error | null; resourceTemplates: ResourceTemplate[]; refresh: () => Promise<ResourceTemplate[]>; } @@ -50,6 +57,8 @@ export function useManagedResourceTemplates( }; }, [managedResourceTemplatesState]); + const error = useManagedListError(managedResourceTemplatesState); + const refresh = useCallback(async (): Promise<ResourceTemplate[]> => { if (!managedResourceTemplatesState || !client) return []; // A user-initiated refresh forces a cache-bypassing round trip @@ -63,5 +72,5 @@ export function useManagedResourceTemplates( return next; }, [client, managedResourceTemplatesState]); - return { resourceTemplates, refresh }; + return { resourceTemplates, error, refresh }; } diff --git a/core/react/useManagedResources.ts b/core/react/useManagedResources.ts index 8fe03172a..127860622 100644 --- a/core/react/useManagedResources.ts +++ b/core/react/useManagedResources.ts @@ -6,8 +6,15 @@ import type { } from "../mcp/state/managedResourcesState.js"; import type { Resource } from "@modelcontextprotocol/client"; import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import { useManagedListError } from "./useManagedListError.js"; export interface UseManagedResourcesResult { + /** + * The last fetch's failure (transport error, or a result the SDK codec + * rejected), or `null` when it succeeded. Includes the connect-time load, + * whose failure has no caller to surface it (#1953). + */ + error: Error | null; resources: Resource[]; /** * True when a `resources/list_changed` arrived since the last user refresh. @@ -76,6 +83,8 @@ export function useManagedResources( }; }, [managedResourcesState]); + const error = useManagedListError(managedResourcesState); + const refresh = useCallback(async (): Promise<Resource[]> => { if (!managedResourcesState || !client) return []; // A user-initiated refresh acknowledges the change — clear the indicator @@ -96,5 +105,5 @@ export function useManagedResources( managedResourcesState?.clearListChanged(); }, [managedResourcesState]); - return { resources, listChanged, refresh, clearListChanged }; + return { resources, error, listChanged, refresh, clearListChanged }; } diff --git a/core/react/useManagedTools.ts b/core/react/useManagedTools.ts index fa71452d9..d7ed1c223 100644 --- a/core/react/useManagedTools.ts +++ b/core/react/useManagedTools.ts @@ -4,8 +4,15 @@ import type { ManagedToolsState } from "../mcp/state/managedToolsState.js"; import type { ManagedToolsStateEventMap } from "../mcp/state/managedToolsState.js"; import type { Tool } from "@modelcontextprotocol/client"; import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import { useManagedListError } from "./useManagedListError.js"; export interface UseManagedToolsResult { + /** + * The last fetch's failure (transport error, or a result the SDK codec + * rejected), or `null` when it succeeded. Includes the connect-time load, + * whose failure has no caller to surface it (#1953). + */ + error: Error | null; tools: Tool[]; /** True when a `tools/list_changed` arrived since the last user refresh. */ listChanged: boolean; @@ -65,6 +72,8 @@ export function useManagedTools( }; }, [managedToolsState]); + const error = useManagedListError(managedToolsState); + const refresh = useCallback(async (): Promise<Tool[]> => { if (!managedToolsState || !client) return []; // A user-initiated refresh acknowledges the change — clear the indicator @@ -85,5 +94,5 @@ export function useManagedTools( managedToolsState?.clearListChanged(); }, [managedToolsState]); - return { tools, listChanged, refresh, clearListChanged }; + return { tools, error, listChanged, refresh, clearListChanged }; } diff --git a/core/react/useServers.ts b/core/react/useServers.ts index 010a1e917..3a2a50028 100644 --- a/core/react/useServers.ts +++ b/core/react/useServers.ts @@ -82,6 +82,18 @@ async function readErrorMessage(res: Response): Promise<string> { return `HTTP ${res.status}`; } +/** + * Whether an SSE frame carries an actual event rather than only comment + * lines. The backend primes each stream with an inert `:` comment so a + * streaming `fetch()` resolves on Firefox at all (#1858); that frame must + * not be mistaken for a change notification. + */ +function isSseDataFrame(frame: string): boolean { + return frame + .split("\n") + .some((line) => line.startsWith("event:") || line.startsWith("data:")); +} + export function useServers(opts: UseServersOptions): UseServersResult { const { baseUrl, authToken, fetchFn } = opts; const doFetch = fetchFn ?? globalThis.fetch; @@ -157,6 +169,10 @@ export function useServers(opts: UseServersOptions): UseServersResult { // single background refresh per decode chunk. Two `change` // broadcasts landing in the same chunk become one re-fetch instead // of two concurrent ones whose setState order is unspecified. + // Frames carrying no `event:`/`data:` field are skipped: the backend + // opens the stream with an inert `:` comment frame so Firefox + // resolves this fetch at all (see SSE_PRIMING_COMMENT in the remote + // server), and that must not read as a change. // Cross-chunk debounce is not added: `awaitWriteFinish`'s 100ms // stability threshold already serializes external edits at the // source, and chained fetches against the same GET endpoint are @@ -169,8 +185,9 @@ export function useServers(opts: UseServersOptions): UseServersResult { let sawFrame = false; let frameEnd = buffer.indexOf("\n\n"); while (frameEnd !== -1) { + const frame = buffer.slice(0, frameEnd); buffer = buffer.slice(frameEnd + 2); - sawFrame = true; + if (isSseDataFrame(frame)) sawFrame = true; frameEnd = buffer.indexOf("\n\n"); } if (sawFrame) void refreshInternal(true); diff --git a/docs/inspector-roadmap-2026-h2.md b/docs/inspector-roadmap-2026-h2.md new file mode 100644 index 000000000..bfcee53de --- /dev/null +++ b/docs/inspector-roadmap-2026-h2.md @@ -0,0 +1,570 @@ +# Inspector Roadmap — August 2026 → February 2027 + +> A six-month plan for the Inspector client family (Web, CLI, TUI), covering both +> **spec-following work** driven by the MCP roadmap and **experience work** we choose +> for ourselves. + +**Horizon:** 2026-08-11 → 2027-02-11 (~26 weekly milestones, `v2.2.0` → ~`v2.27.0`) +**Owner:** [Inspector V2 WG](https://modelcontextprotocol.io/community/working-groups/inspector-v2) +**Status:** Draft for WG review + +--- + +## Table of Contents + +- [1. Why this document exists](#1-why-this-document-exists) +- [2. The two tracks](#2-the-two-tracks) +- [3. Track A — following the spec](#3-track-a--following-the-spec) + - [3.1 Transport evolution and scalability](#31-transport-evolution-and-scalability) + - [3.2 Server Cards](#32-server-cards) + - [3.3 Agent communication and Tasks](#33-agent-communication-and-tasks) + - [3.4 Enterprise readiness](#34-enterprise-readiness) + - [3.5 Triggers and events](#35-triggers-and-events) + - [3.6 Result type improvements](#36-result-type-improvements) + - [3.7 Interceptors](#37-interceptors) + - [3.8 File uploads](#38-file-uploads) + - [3.9 Skills over MCP](#39-skills-over-mcp) + - [3.10 Primitive grouping and tool annotations](#310-primitive-grouping-and-tool-annotations) + - [3.11 Conformance and validation](#311-conformance-and-validation) +- [4. Track B — experience work we choose](#4-track-b--experience-work-we-choose) + - [4.1 The zoomable timeline (headline)](#41-the-zoomable-timeline-headline) + - [4.2 Session record, replay, and share](#42-session-record-replay-and-share) + - [4.3 Diff and compare](#43-diff-and-compare) + - [4.4 Command palette and global search](#44-command-palette-and-global-search) + - [4.5 Saved calls and collections](#45-saved-calls-and-collections) + - [4.6 Assertions and CI flows](#46-assertions-and-ci-flows) + - [4.7 The argument editor workstream](#47-the-argument-editor-workstream) + - [4.8 Connection Doctor](#48-connection-doctor) + - [4.9 Server management and portability](#49-server-management-and-portability) + - [4.10 Workspace and layout](#410-workspace-and-layout) + - [4.11 Performance at scale](#411-performance-at-scale) + - [4.12 Accessibility and keyboard-first operation](#412-accessibility-and-keyboard-first-operation) + - [4.13 Onboarding](#413-onboarding) + - [4.14 Plugin architecture](#414-plugin-architecture) +- [5. Sequencing](#5-sequencing) +- [6. What we are deliberately not doing](#6-what-we-are-deliberately-not-doing) +- [7. Open questions](#7-open-questions) +- [8. Sources](#8-sources) + +--- + +## 1. Why this document exists + +Through v1, the Inspector was a **follow-along project**. The spec moved, we chased it, and +whatever planning capacity remained went to keeping up rather than to the tool's own design. +Every release was reactive by necessity. + +That constraint has lifted. v2 meets the 2026-07-28 spec across all three clients, on SDK v2, +with a shared `core/`, a ≥90% per-file coverage gate, and a smoke/e2e apparatus that catches +packaging failures. For the first time we can spend planned effort on **what the Inspector +should be**, not only on what the spec just became. + +This document splits the next six months into those two kinds of work, so that neither +starves the other. The explicit intent is a **roughly even split of capacity** — spec-following +work is non-negotiable but bounded, and the remaining capacity is ours to direct. + +> **Sourcing note.** The MCP roadmap circulated as a Google Doc ("MCP Roadmap Process and +> Timeline") requires authentication and could not be read directly. This plan is built from +> the **published** roadmap at `modelcontextprotocol.io/development/roadmap` (last updated +> 2026-03-05) plus the current WG and IG charters, which together cover the same themes at +> more implementation-relevant detail. If the private doc contains timelines or themes absent +> from the public page, §3 should be revised against it before the plan is adopted. + +--- + +## 2. The two tracks + +| | **Track A — Spec-following** | **Track B — Experience** | +| --------------------------- | ----------------------------------------------------- | ------------------------------------------- | +| **Driver** | MCP roadmap, WG deliverables, SEP acceptance | Our own judgment about the tool | +| **Trigger to start** | A SEP reaches Draft with a Tier-1 SDK reference impl | Whenever we have capacity | +| **Risk** | Slips when upstream slips; we cannot control the date | We control the date entirely | +| **Failure mode if starved** | Inspector stops being the reference test client | Inspector stays a protocol dump, not a tool | +| **Target capacity** | ~50% | ~50% | + +The two tracks are not independent. Several Track B items — the timeline, session +record/replay, diff — are **force multipliers for Track A**: each new protocol feature +arrives with a rendering problem, and a general timeline plus a general diff is cheaper than +one bespoke panel per SEP. That is the core scheduling argument of this plan: **build the +general surfaces early so the spec work that lands later is cheap to display.** + +### How the Inspector's role is changing + +Worth stating plainly, because it shapes the priorities below. The roadmap's Validation +section names **conformance test suites**, **SDK tiers**, and **reference implementations** as +standing investments, and SEP-2484 now requires conformance tests for final SEPs. The +Inspector is the most visible MCP client in the ecosystem and is already the thing people +reach for when a server misbehaves. + +That points at an expanded role: not just _"show me the traffic"_ but _"tell me whether this +server is correct."_ Several items below (Server Card diffing, the conformance runner, +assertions, the readiness summary) are steps toward that, and they should be evaluated as a +group rather than individually. + +--- + +## 3. Track A — following the spec + +Each subsection states the upstream theme, our read on what it means for the Inspector, and a +concrete feature list. **Confidence** flags how much of the list we can commit to now: + +- 🟢 **Build now** — the shape is known; blocked only on our own capacity. +- 🟡 **Design now, build on signal** — enough detail to design against; wait for a Draft SEP or a Tier-1 SDK impl before building. +- 🔴 **Watch** — too early to predict a UI; keep a tracking issue and a WG liaison. + +### 3.1 Transport evolution and scalability + +**Upstream:** Transports WG. Next-generation Streamable HTTP that runs statelessly across +multiple instances and behaves correctly behind load balancers and proxies; a session model +covering creation, resumption, and migration; conformance guidance for SDK authors. The +roadmap is explicit that **no additional official transports** ship this cycle. + +**Read:** This is the theme most likely to produce breaking wire changes, and the one where +the Inspector is most useful — session resumption and proxy behavior are exactly the failures +nobody can reproduce by reading code. Our era model (`legacy` / `modern` / `auto`) already +gives us the negotiation seam to add a third era behind. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Session lifecycle lane** — session id, creation, resumption, migration, and expiry as first-class events, not log lines | 🟢 | Renders into the timeline (§4.1). Buildable against today's session model; extends to the new one. | +| **`Last-Event-ID` resumption support and display** | 🟢 | Existing gap — [#920](https://github.com/modelcontextprotocol/inspector/issues/920). Do it now; it is table stakes for the new session work. | +| **Proxy / intermediary harness** — route through a configurable proxy, then deliberately misbehave: rewrite headers, drop the GET stream, close mid-response | 🟡 | Builds on [#1684](https://github.com/modelcontextprotocol/inspector/issues/1684). Needs a `misbehaving-proxy` preset in `test-servers/`. | +| **Stateless-mode verification** — issue the same request across N synthetic instances and diff the responses | 🟡 | Directly tests the property the WG is specifying. Pairs with §4.3. | +| **Third protocol era behind the existing negotiation seam** | 🟡 | Cost is low _if_ we keep era-conditional exposure rather than replacing the legacy path. | +| **Custom transport support** | 🟢 | [#1741](https://github.com/modelcontextprotocol/inspector/issues/1741). The roadmap pushes experimentation to custom transports, so the Inspector should be able to load one. | + +### 3.2 Server Cards + +**Upstream:** Server Card WG, [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127) (Draft). A standard `.well-known` document exposing structured server metadata so browsers, crawlers, and registries can discover capabilities **without connecting**. Deliberately kept close to a subset of `server.json`. + +**Read:** This is the single highest-leverage Track A item for us, because it creates a new +Inspector capability rather than a new panel: **inspect before connect**. It also creates an +obvious correctness question that only a tool like ours can answer. + +| Feature | Confidence | Notes | +| ----------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Card preview** — paste a URL, fetch the card, render the capability surface, one-click add to catalog | 🟡 | The pre-connection entry point. Wait for the format to settle. | +| **Card-vs-reality diff** — compare the advertised card against what `initialize` + `*/list` actually return | 🟡 | _The_ Inspector-shaped feature here. Nobody else in the ecosystem is positioned to check this. Shares machinery with [#1034](https://github.com/modelcontextprotocol/inspector/issues/1034) and §4.3. | +| **`mcp-inspector --card-lint <url>`** — validate a card, non-zero exit on drift | 🟡 | CI-usable; a natural companion to the conformance runner (§3.11). | +| **`server.json` support** | 🟢 | [#922](https://github.com/modelcontextprotocol/inspector/issues/922). Prerequisite — the card is a subset, so this lands first regardless. | + +### 3.3 Agent communication and Tasks + +**Upstream:** Agents WG. Tasks (`io.modelcontextprotocol/tasks`, SEP-2663) is being +**stabilized and promoted from an extension into core**. Named open gaps: **retry semantics** +(what happens on transient failure, who decides to retry) and **expiry policies** (result +retention, how clients learn a result expired). An Agents Extension is under evaluation. + +**Read:** We already drive the modern Tasks extension ourselves over a raw-wire channel, +because SDK v2 era-gates `tasks/*` out. Promotion to core will move that back under the SDK — +plan for the migration, but **keep the era-conditional exposure**; the legacy `capabilities.tasks` +path must keep working. + +| Feature | Confidence | Notes | +| -------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------- | +| **Retry visualization** — attempts, backoff, who initiated each retry | 🟡 | Design against the WG's gap list now. | +| **Expiry / TTL surfacing** — retention countdown on a completed task, distinct rendering for an expired-result error | 🟡 | Cheap once the semantics land; easy to get wrong if we guess early. | +| **Tasks as timeline spans** — a long-running task is a span, not a row | 🟢 | Falls out of §4.1 for free. The strongest argument for building the timeline first. | +| **Extension → core migration** | 🟡 | Retire the raw-wire channel when the SDK covers it; keep both paths during overlap. | +| **`Mcp-Name` header on Tasks over Streamable HTTP** | 🟢 | [#1917](https://github.com/modelcontextprotocol/inspector/issues/1917) — open bug, fix now. | +| **Discover checkmarks for task extensions** | 🟢 | [#1887](https://github.com/modelcontextprotocol/inspector/issues/1887). | + +### 3.4 Enterprise readiness + +**Upstream:** An Enterprise WG is expected to form. Four named areas: **audit trails and +observability**, **enterprise-managed auth** (Cross-App Access / ID-JAG), **gateway and proxy +patterns**, and **configuration portability**. Most output is expected as extensions rather +than core spec changes. Related: the Enterprise-Managed Authorization IG, and sponsored work +on [SEP-1932 (DPoP)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1932) and [SEP-1933 (Workload Identity Federation)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1933). + +**Read:** "Audit trails and observability, in a form enterprises can feed into their existing +pipelines" is a description of something the Inspector nearly already has. We hold the entire +session; we simply cannot **export** it in any pipeline-shaped format. That gap is cheap to +close and disproportionately valuable. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **OTLP export** — emit the session as OpenTelemetry spans; show trace/span ids inline; "copy as trace" | 🟢 | SEP-414 already puts trace context in `_meta`. Buildable today, no upstream dependency. | +| **Structured audit transcript** — the full session as a stable, documented JSON artifact | 🟢 | Shares its format with §4.2 record/replay. Build once, use for both. | +| **Machine-readable readiness summary** | 🟢 | [#1916](https://github.com/modelcontextprotocol/inspector/issues/1916). | +| **ID-JAG / Cross-App Access test flow** | 🟡 | The EMA IG exists specifically because this only works when IdP + client + AS interoperate. A test client is exactly what they lack. Related: [#1937](https://github.com/modelcontextprotocol/inspector/issues/1937), [#571](https://github.com/modelcontextprotocol/inspector/issues/571). | +| **DPoP and Workload Identity Federation** | 🔴 | Both sponsored but pre-acceptance. Watch; do not build. | +| **Gateway mode** — declare an intermediary, then show what we sent vs. what the gateway forwarded | 🟡 | Depends on the Gateways IG settling propagation semantics. | +| **Configuration portability** | 🟢 | [#1912](https://github.com/modelcontextprotocol/inspector/issues/1912), [#904](https://github.com/modelcontextprotocol/inspector/issues/904), plus `server.json` (§3.2). | + +### 3.5 Triggers and events + +**Upstream:** Triggers and Events WG. A standardized server→client callback mechanism +(webhooks or similar), with subscription lifecycle and **ordering guarantees that hold across +all transports**. Status: "SEP: Events in MCP v1 RFC" — **Ideating**. + +**Read:** ⚠️ **This is the largest architectural change on the horizon for us, and the one we +are least prepared for.** Every Inspector surface today assumes we are the party that +_initiated_ the connection. A webhook mechanism makes us a **server** — we must host a +publicly reachable callback endpoint, which for a tool that usually runs on `localhost` is a +real problem (tunnels, port forwarding, or a relay). + +We should start the design conversation **now**, well ahead of the SEP, and bring it to the +WG as implementation feedback. The ordering-guarantee requirement in particular is +untestable without a client that records arrival order — which is us. + +| Feature | Confidence | Notes | +| ----------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Callback receiver** — backend-hosted endpoint, its URL registered as the trigger target | 🔴 | Needs design now, build later. Security review mandatory: an inbound public endpoint on a process that spawns subprocesses is a serious surface. | +| **Local reachability story** — tunnel integration or documented guidance | 🔴 | Likely the hardest UX problem of the whole six months. | +| **Delivery log with ordering and duplicate assertions** | 🔴 | The conformance value: did events arrive in the promised order? were any redelivered? | + +### 3.6 Result type improvements + +**Upstream:** "On the Horizon." **Streamed results** (incremental output for generated text, +audio, video frames) and **reference-based results** (client decides when to pull a large +payload into context). Explicitly cross-cutting — streaming touches transport, references +touch the schema. + +**Read:** Streaming changes how every result panel renders: today we display a _result_, and +we would need to display a _stream that becomes a result_. Worth a rendering abstraction +before the SEP, not after. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------- | +| **Incremental result rendering** — progressive display, with time-to-first-chunk and inter-chunk timing | 🔴 | The timing view is Inspector-shaped; the timeline is the natural home. | +| **Reference-result handling** — show a handle plus an explicit "pull payload", with size accounting | 🔴 | Also a good default for large payloads _today_, independent of the SEP (see §4.11). | + +### 3.7 Interceptors + +**Upstream:** Interceptors WG, [SEP-1763](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2076) (Draft). Interceptors as a new primitive with two types — **validators** (pass/fail) and **mutators** (transform payloads) — across in-process, sidecar, and remote deployment models, with priority-based chain ordering and audit-mode semantics. A **CLI client for interceptor invocation and testing** is a listed WG deliverable (Ideating, unowned). + +**Read:** Two things stand out. First, "CLI client for interceptor invocation and testing" is +**an unclaimed deliverable that describes our CLI**. Worth raising with the WG — Ola co-leads +both groups, so the liaison already exists. Second, an interceptor chain is a +_before → after payload transformation_, which is a diff, which we should already be able to +render (§4.3). + +| Feature | Confidence | Notes | +| ---------------------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------- | +| **Interceptor test bench** — register a chain, show before/after diff per hop, visualize priority ordering | 🟡 | The clearest "Inspector as the reference tool" opportunity of the six months. | +| **Audit-mode rendering** — what _would_ have been blocked or mutated | 🟡 | Follows the SEP's audit semantics. | +| **CLI interceptor invocation** | 🟡 | **Action: raise with the Interceptors WG.** If we take it, it needs its own milestone allocation. | +| **Our plugin architecture as an interceptor host** | 🟡 | [#1025](https://github.com/modelcontextprotocol/inspector/issues/1025). Prevents us building two extension mechanisms. | + +### 3.8 File uploads + +**Upstream:** File Uploads WG, [SEP-2356](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2356) (Draft, TS SDK reference impl targeted End May). Declarative `FileInputDescriptor` on tool input schemas and elicitation schemas, so hosts render native file pickers. Success criteria explicitly include **"at least one production host rendering a native file picker from the descriptor."** + +**Read:** The most tractable Track A item on the list — narrow, well-specified, with a TS SDK +reference implementation coming, and we are a credible candidate for that "production host." +It touches three surfaces: `SchemaForm` (Tools), elicitation forms, and MCP Apps. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------ | ---------- | ------------------------------------------------ | +| **File picker in `SchemaForm`** when a descriptor is present, with data-URI encoding | 🟡 | Wait for the TS SDK types, then build. Low risk. | +| **Same in elicitation forms** | 🟡 | Shared component. | +| **Size guardrails and host-side validation** | 🟡 | The SEP references OWASP ASVS V5. | + +### 3.9 Skills over MCP + +**Upstream:** Skills Over MCP WG, [SEP-2640](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640) (In Review, Extensions Track). Resources-based; a reference implementation is also In Review. + +**Read:** Because it is Resources-based, the incremental cost is low — a Skills view over the +existing resource machinery rather than a new subsystem. + +| Feature | Confidence | Notes | +| -------------------------------------------------------- | ---------- | -------------------------------------------------------------------- | +| **Skills view** — list, preview content, show activation | 🟡 | Gate on the negotiated extension, the way the Tasks tab gates today. | + +### 3.10 Primitive grouping and tool annotations + +**Upstream:** Two IGs. **Primitive Grouping** explores organizing Tools/Resources/Prompts +beyond flat lists — deliberately not picking one canonical pattern early. **Tool Annotations** +is consolidating six independent annotation SEPs and considering runtime annotations and tool +_response_ annotations. + +**Read:** Grouping is the rare case where the spec-following work and the UX work are the same +work. Flat lists are already our weakest surface on large servers — [#1957](https://github.com/modelcontextprotocol/inspector/issues/1957) (duplicate tool names) was a symptom. **Build the grouped sidebar as a UX +improvement now**, and adopt whatever grouping the IG lands as a data source later. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------- | +| **Grouped / tree sidebars with group-aware search** | 🟢 | Build now on client-side heuristics (name prefixes, annotations). Ship value immediately; swap the data source later. | +| **Richer annotation rendering** | 🟢 | Extends the existing `AnnotationBadge`. | +| **Annotation-driven confirmation** before a `destructiveHint` call | 🟢 | Small, obviously correct, no upstream dependency. | +| **Runtime / response annotations** | 🔴 | Watch. | + +### 3.11 Conformance and validation + +**Upstream:** Standing investment — conformance test suites, SDK tiers ([SEP-1730](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1730)), reference implementations. [SEP-2484](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2484) now **requires conformance tests for final SEPs**, and the EMA IG is explicitly contributing scenarios to the `modelcontextprotocol/conformance` repository. + +**Read:** A conformance suite needs a driver and a report. We are the natural driver, and we +already have a CLI that exits non-zero. This is the clearest path to the expanded role +described in §2 — and unlike most of Track A, **it is not gated on any SEP**. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Conformance runner** — run the suite against a connected server, render pass/fail per assertion | 🟡 | Needs coordination on the suite's programmatic interface. **Action: open a conversation with the conformance maintainers.** | +| **`mcp-inspector --conformance` for CI** | 🟡 | Same engine, CLI report, exit code. | +| **Strict schema validation with actionable errors** | 🟢 | [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1015](https://github.com/modelcontextprotocol/inspector/issues/1015). No dependency; start here. | + +--- + +## 4. Track B — experience work we choose + +Nothing in this section waits on a SEP. Ordered by leverage, not by effort. + +### 4.1 The zoomable timeline (headline) + +**Committed.** The single feature that most changes what the Inspector _is_. + +The Protocol and Network screens are chronological lists. A list answers "what happened next" +but not "what happened _at the same time_", "how long did this take", or "which of these +caused that" — and those are the questions people actually bring to the Inspector. A +session with an MRTR round-trip, a long-running task, a subscription stream, and a +mid-session OAuth step-up is, in list form, an interleaved mess. On a time axis it is legible +at a glance. + +**Design sketch:** + +- **A third view over the existing stores**, not a new data path. Protocol, Network, and + Timeline become three renderings of one session. This keeps the coverage gate and the + existing `protocolUtils` derivations intact. +- **Lanes**, each independently collapsible: + `client → server` · `server → client` · notifications · tasks · subscription streams · OAuth/auth · errors +- **Spans, not points.** A request occupies from send to response; a task occupies its whole + lifetime; a stream is a bar with events on it. Duration becomes visible, which is most of + the value. +- **Zoom and pan** across the full range, from whole-session down to sub-millisecond. + Brush-to-select a range and filter every other view to it. +- **Grouping** — an MRTR conversation is one collapsible span containing its rounds; a task + contains its polls. +- **Click through** to the existing Protocol/Network entry. The timeline is navigation, not a + replacement. +- **A pinned mini-timeline strip** above every tab, so a spike is visible while you are in + Tools, and clicking it jumps to the full view. +- **Latency distribution** as a secondary view — per method, so a slow tool is obvious. +- **Virtualized**, keyboard-navigable, and rendered from the same store the other views use. + +**Deliberately out of scope for v1 of this feature:** cross-server correlation (needs §4.10), +and OTLP-shaped nesting (needs §3.4). + +### 4.2 Session record, replay, and share + +Save a complete session — protocol log, network log, server config, negotiated capabilities — +to a single file. Reopen it later, on another machine, with no server running. Attach it to a +bug report. + +This changes issue triage from "works on my machine" into an artifact, and it is the same +serialization format as the enterprise audit transcript (§3.4) — **build the format once**. +Replay also gives us fixtures: a recorded session is a regression test. + +### 4.3 Diff and compare + +Two sessions, or two servers, side by side. Concretely: + +- **Capability diff** — reconnect after changing your server, see exactly what moved in + `tools/list` / `resources/list` / `prompts/list`. ([#1034](https://github.com/modelcontextprotocol/inspector/issues/1034)) +- **Session diff** — same calls, two servers, what differed. +- **Payload diff** — before/after for any pair of JSON documents. + +The payload differ is a **shared primitive**: interceptor before/after (§3.7), Server +Card-vs-reality (§3.2), and stateless-instance comparison (§3.1) are all the same widget with +different inputs. Build it as a component first, then wire the three consumers. + +### 4.4 Command palette and global search + +`⌘K` to jump to any server, tool, resource, or prompt; re-run the last call; switch tabs. Plus +full-text search across the protocol log with a real filter syntax (`method:tools/call +status:error duration:>500ms`). The Inspector is currently a mouse-driven app; for a developer +tool that is a daily tax. + +### 4.5 Saved calls and collections + +Name a tool call with its arguments, save it, re-run it, parameterize it, share it. A +Postman-collection model for MCP. The single most requested shape of workflow improvement for +any protocol client, and it composes directly with §4.6. + +### 4.6 Assertions and CI flows + +Attach expectations to a saved call — result matches schema, field equals value, latency under +a bound — and run the collection from the CLI with a non-zero exit on failure. This turns the +Inspector from an interactive tool into part of a server author's test suite, and it shares an +engine with the conformance runner (§3.11). +Related: [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1886](https://github.com/modelcontextprotocol/inspector/issues/1886), [#1916](https://github.com/modelcontextprotocol/inspector/issues/1916). + +### 4.7 The argument editor workstream + +Six open issues are all the same defect class — the argument editor is not schema-aware: + +| Issue | Symptom | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------- | +| [#1853](https://github.com/modelcontextprotocol/inspector/issues/1853) | JSON parameter editor escaping while typing | +| [#1856](https://github.com/modelcontextprotocol/inspector/issues/1856) | Backspace recursively escapes JSON tool inputs | +| [#1885](https://github.com/modelcontextprotocol/inspector/issues/1885) | Null values corrupted with cascading escapes | +| [#1928](https://github.com/modelcontextprotocol/inspector/issues/1928) | Nullable enums fall back to a broken raw Textarea (v1.x regression) | +| [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919) | Resource templates lack RFC 6570 expansion | +| [#1910](https://github.com/modelcontextprotocol/inspector/issues/1910) | Complex `_meta` not expressible | + +**Fix them as one workstream, not six bugs.** A proper schema-aware editor (CodeMirror or +Monaco with JSON Schema integration) resolves the class and unblocks file inputs (§3.8) and +strict validation (§3.11). Treating them individually has already produced one regression from +v1. + +### 4.8 Connection Doctor + +Connection failures are currently opaque, and five open issues say so +([#962](https://github.com/modelcontextprotocol/inspector/issues/962), [#1936](https://github.com/modelcontextprotocol/inspector/issues/1936), [#1951](https://github.com/modelcontextprotocol/inspector/issues/1951), [#1944](https://github.com/modelcontextprotocol/inspector/issues/1944), [#1914](https://github.com/modelcontextprotocol/inspector/issues/1914)). + +Run an ordered checklist on failure — DNS · TCP · TLS (including local-cert cases) · +`/.well-known` discovery · protocol version negotiation · auth — and report **which step +failed and what to do about it**. First-connection success is the entire first impression of +the tool, and today a `https://localhost` server or a dev container silently fails. + +Bundle the related fixes: `*.localhost` domains ([#1944](https://github.com/modelcontextprotocol/inspector/issues/1944)), the trusted-local-host OAuth HTTP +exception ([#1911](https://github.com/modelcontextprotocol/inspector/issues/1911)), and the ghost-server entry left by a failed manual connect ([#1914](https://github.com/modelcontextprotocol/inspector/issues/1914)). + +### 4.9 Server management and portability + +Already well represented on the board; grouping it here so it is scheduled as a theme rather +than piecemeal: rich server configuration ([#1857](https://github.com/modelcontextprotocol/inspector/issues/1857)), custom headers and cookies ([#1915](https://github.com/modelcontextprotocol/inspector/issues/1915)), +auth/token URL overrides ([#1906](https://github.com/modelcontextprotocol/inspector/issues/1906)), file-backed secrets where no OS keychain exists ([#1950](https://github.com/modelcontextprotocol/inspector/issues/1950)), +paste-MCP-JSON ([#904](https://github.com/modelcontextprotocol/inspector/issues/904)), and registry discovery ([#1101](https://github.com/modelcontextprotocol/inspector/issues/1101)). + +### 4.10 Workspace and layout + +Multiple servers side by side — the actual shape of debugging a gateway, or comparing a +server against a reference implementation. Detachable/resizable panels, remembered layout per +server, density modes, and full-collapse ([#928](https://github.com/modelcontextprotocol/inspector/issues/928)). Prerequisite for cross-server timeline +correlation. + +### 4.11 Performance at scale + +A 1000-tool server or a long-running session should not degrade. Virtualize the long lists and +logs; cap in-memory protocol history with spill-to-disk; truncate large payloads by default +with explicit expansion (which is also the right default for reference results, §3.6). + +### 4.12 Accessibility and keyboard-first operation + +Full keyboard operation across every tab, correct roles and labels, high-contrast support, +and `prefers-reduced-motion` (which the timeline's animations will make newly relevant). We +have a Storybook a11y harness already; the gap is coverage, not tooling. + +### 4.13 Onboarding + +A first run currently presents an empty server list and no path forward. Add a guided first +connection, one-click example servers drawn from `test-servers/`, and inline links from each +panel to the relevant spec section. + +### 4.14 Plugin architecture + +[#1025](https://github.com/modelcontextprotocol/inspector/issues/1025). The multiplier on everything above — custom panels, custom transports (§3.1), +interceptor hosting (§3.7), and community-contributed views without core changes. Sequenced +late deliberately: designing a plugin API before the timeline, diff, and session format exist +would mean designing it against the wrong surfaces. + +--- + +## 5. Sequencing + +Four phases of roughly six weekly milestones each. Track A items appear where their upstream +signal is expected; Track B items are placed to unblock Track A wherever possible. + +### Phase 1 — Foundations (~`v2.2` – `v2.7`, Aug–Sep 2026) + +_Build the general surfaces the rest of the plan renders into, and clear the debt that makes +first impressions bad._ + +- 🅑 **Zoomable timeline v1** — lanes, spans, zoom/pan, click-through +- 🅑 **Argument editor workstream** (§4.7) — closes six issues as one +- 🅑 **Connection Doctor** (§4.8) + the local-host connection fixes +- 🅐 `Last-Event-ID` resumption ([#920](https://github.com/modelcontextprotocol/inspector/issues/920)); `Mcp-Name` on Tasks ([#1917](https://github.com/modelcontextprotocol/inspector/issues/1917)); discover checkmarks ([#1887](https://github.com/modelcontextprotocol/inspector/issues/1887)) +- 🅐 `server.json` support ([#922](https://github.com/modelcontextprotocol/inspector/issues/922)) — prerequisite for Server Cards +- ⚙️ Windows CI/gate fixes already in `v2.2.0` + +### Phase 2 — Artifacts and comparison (~`v2.8` – `v2.13`, Sep–Nov 2026) + +_Make sessions into things you can keep, share, and compare._ + +- 🅑 **Session record / replay / share** (§4.2) — format shared with audit transcript +- 🅑 **Diff primitive** (§4.3) — then wire capability diff ([#1034](https://github.com/modelcontextprotocol/inspector/issues/1034)) +- 🅑 **Command palette and global search** (§4.4) +- 🅐 **OTLP export and audit transcript** (§3.4) — no upstream dependency +- 🅐 **Grouped sidebars** (§3.10) on client-side heuristics +- 🅐 Strict schema validation ([#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1015](https://github.com/modelcontextprotocol/inspector/issues/1015)) +- 🅐 Timeline lanes for tasks and sessions (falls out of Phase 1) + +### Phase 3 — Automation and spec catch-up (~`v2.14` – `v2.20`, Nov 2026 – Jan 2027) + +_Turn the Inspector into something you can run in CI, and absorb the SEPs that have landed._ + +- 🅑 **Saved calls / collections** (§4.5) → **assertions and CI flows** (§4.6) +- 🅐 **Conformance runner** (§3.11) — shares the assertion engine +- 🅐 **File uploads** (§3.8) — assumes the TS SDK reference impl has shipped +- 🅐 **Server Card preview + card-vs-reality diff** (§3.2) — assumes SEP-2127 has settled +- 🅐 **Skills view** (§3.9) — assumes SEP-2640 accepted +- 🅑 Performance at scale (§4.11); accessibility pass (§4.12) + +### Phase 4 — Frontier (~`v2.21` – `v2.27`, Jan–Feb 2027) + +_The items whose shape we cannot yet commit to, plus the multiplier._ + +- 🅐 **Interceptor test bench** (§3.7) — and a decision on owning the WG's CLI deliverable +- 🅐 **Triggers/events receiver** (§3.5) — design throughout, build only if the SEP lands +- 🅐 **Transport/session work** (§3.1) — proxy harness, stateless verification, third era +- 🅐 **ID-JAG / Cross-App Access flow** (§3.4) +- 🅑 **Plugin architecture** (§4.14) — designed against surfaces that now exist +- 🅑 Workspace and layout (§4.10); onboarding (§4.13) + +### Standing commitments across all phases + +- **Weekly milestone cadence** and the `npm run ci` gate are unchanged. +- **Bug and triage capacity is reserved, not scheduled.** The board's Incoming queue keeps + flowing regardless of phase. +- **WG liaison**: attend Transports, Agents, Triggers, Interceptors, and Server Card sessions + and feed implementation experience back. Several items above are as much _inputs to_ the + spec as outputs of it. + +--- + +## 6. What we are deliberately not doing + +Stating these so they are decisions rather than oversights. + +- **Not building bespoke panels per SEP.** Where a new feature can render into the timeline, + the diff, or the session format, it does. A new top-level tab needs justification. +- **Not chasing pre-Draft SEPs.** 🔴 items get a tracking issue and a WG liaison, not code. + We were burned by this in v1. +- **Not publishing `core/` as a package this cycle.** [#1636](https://github.com/modelcontextprotocol/inspector/issues/1636) stays deferred; it adds an API + compatibility obligation we cannot yet afford. +- **Not adding transports beyond what the spec blesses**, per the roadmap — but §3.1 makes + _custom_ transports loadable so the community can experiment. +- **Not building a second extension mechanism.** If we host interceptors, they run on the + plugin architecture (§4.14). + +--- + +## 7. Open questions + +For WG discussion before this plan is adopted. + +1. **Does the private roadmap doc change §3?** This plan is built from the public roadmap; the + private doc may carry timelines or themes it omits. +2. **Do we claim the Interceptors WG's "CLI client for interceptor invocation and testing"?** + It is Ideating and unowned, it describes our CLI, and we have a co-lead in common. If yes, + it needs milestone allocation in Phase 3, not Phase 4. +3. **How far do we take the conformance role?** §3.11 and §4.6 point at "the Inspector tells + you whether your server is correct." That is a real expansion of mission — worth an + explicit yes or no, and possibly a charter amendment. +4. **Who owns the triggers/events reachability problem?** A publicly reachable callback + endpoint on a localhost dev tool is a security question as much as a UX one, and it needs + an owner before Phase 4. +5. **Is the ~50/50 capacity split right?** It is an assertion in this draft, not a measurement. +6. **Timeline v1 scope.** The §4.1 sketch is deliberately broad. Which parts are v1 and which + are follow-ups should be settled before Phase 1 starts. + +--- + +## 8. Sources + +- [MCP Roadmap](https://modelcontextprotocol.io/development/roadmap) (last updated 2026-03-05) +- WG charters: [Inspector V2](https://modelcontextprotocol.io/community/working-groups/inspector-v2) · [Server Card](https://modelcontextprotocol.io/community/working-groups/server-card) · [Triggers & Events](https://modelcontextprotocol.io/community/working-groups/triggers-events) · [Agents](https://modelcontextprotocol.io/community/working-groups/agents) · [Interceptors](https://modelcontextprotocol.io/community/working-groups/interceptors) · [File Uploads](https://modelcontextprotocol.io/community/working-groups/file-uploads) · [Skills Over MCP](https://modelcontextprotocol.io/community/working-groups/skills-over-mcp) +- IG charters: [Primitive Grouping](https://modelcontextprotocol.io/community/interest-groups/primitive-grouping) · [Tool Annotations](https://modelcontextprotocol.io/community/interest-groups/tool-annotations) · [Enterprise-Managed Authorization](https://modelcontextprotocol.io/community/interest-groups/enterprise-managed-authorization) +- Internal: [`specification/v2_new_spec_impact.md`](../specification/v2_new_spec_impact.md) · [`specification/v2_scope.md`](../specification/v2_scope.md) · [`specification/v2_ux_features.md`](../specification/v2_ux_features.md) +- [Inspector V2 project board (#28)](https://github.com/orgs/modelcontextprotocol/projects/28) diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index 98336df1d..00455a244 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -99,7 +99,13 @@ They differ on **when you need it**, though. Web sets `allowUnknownOption()` + ` mcp-inspector --cli node build/index.js -- --method tools/list ``` -So under `--cli` the separator does **not** protect an argument from the Inspector — it does the opposite, and the web example above would have `--config /etc/myserver.conf` consumed as a read-only-session flag (then rejected as a catalog/ad-hoc conflict). There is currently no way to pass a leading-dash argument through to a stdio server on the `--cli` command line; put it in the server entry's `args` in a catalog or config file instead. +So under `--cli` the separator does **not** protect an argument from the Inspector — it does the opposite, and the web example above, run verbatim, would have `--config /etc/myserver.conf` consumed as a read-only-session flag (then rejected as a catalog/ad-hoc conflict). Leading-dash arguments for the server still get through; they just go on the other side of the separator, where the whole pre-`--` run is taken as the target verbatim: + +```bash +mcp-inspector --cli node build/index.js --config /etc/myserver.conf --verbose -- --method tools/list +``` + +Without a `--` on the line the target is only the leading run of **non-dash** tokens, so the separator is required whenever the server itself takes flags. ## The shared flags @@ -201,5 +207,6 @@ The CLI and TUI do not perform catalog CRUD yet — they are read consumers — ## Related +- [Migrating from v1 to v2](./v1-to-v2-migration.md) — why `--config` means something narrower than it did in v1, and what `--catalog` replaced. - [Launcher and config consolidation](./launcher-config-consolidation-plan.md) — how the launcher and the shared config processor fit together. - [Reviewing an MCP App](./mcp-app-review.md) — the CLI-first App review recipe. diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md new file mode 100644 index 000000000..a2db3f55a --- /dev/null +++ b/docs/v1-to-v2-migration.md @@ -0,0 +1,368 @@ +# Migrating from Inspector v1 to v2 + +v2 is a rewrite. The package name and the `npx @modelcontextprotocol/inspector` entry point are unchanged, but the architecture, the CLI surface, and the way you point the Inspector at a server all changed. + +This guide is the map. It covers what breaks, what it becomes, and the handful of places where a v1 command still runs in v2 but means something different — the ones worth reading before you conclude v2 is broken. + +> **v1 is deprecated** and receives security fixes only, published to the `v1-latest` npm dist-tag. To stay on it, pin it explicitly: +> +> ```bash +> npx @modelcontextprotocol/inspector@v1-latest +> ``` + +## At a glance + +| | v1 | v2 | +| ----------------- | ------------------------------------------------ | --------------------------------------------------------------- | +| Node | `>=22.7.5` | **`>=22.19.0`** | +| npm packages | 4 (`inspector` + `-client` / `-server` / `-cli`) | **1** (`@modelcontextprotocol/inspector`) | +| Processes / ports | web UI on `6274` **+ MCP proxy on `6277`** | one web server on `6274` (plus a dynamic MCP Apps sandbox port) | +| Modes | web, `--cli` | web, `--cli`, **`--tui`** | +| Server list | per-browser, in `localStorage` | a **catalog file** on disk (`~/.mcp-inspector/mcp.json`) | +| Auth token env | `MCP_PROXY_AUTH_TOKEN` | **`MCP_INSPECTOR_API_TOKEN`** | +| CLI exit codes | `0` / `1` | `0`–`5`, plus a JSON error envelope on stderr | + +The three changes most likely to bite an existing setup: + +1. **`--config` no longer means what it did** — see [`--config` vs `--catalog`](#--config-vs---catalog). +2. **A failing tool call now exits non-zero** (`5`), where v1 exited `0`. CI scripts that ran `&&` chains past a failed call will start failing — correctly. +3. **`SERVER_PORT` was the proxy port and now isn't** — it survives as a fallback for the MCP Apps sandbox port. Setting it no longer moves anything you can browse. + +## Requirements + +v2 requires **Node `>=22.19.0`** (v1 required `>=22.7.5`). The floor comes from `undici@^8`, used for HTTP proxy support. npm only _warns_ about an `engines` mismatch (`EBADENGINE`) unless you have `engine-strict=true` set, so an older Node won't stop you at install time — it fails later, obscurely. Check `node -v` first. + +## What no longer ships + +v1 published four packages: + +- `@modelcontextprotocol/inspector` +- `@modelcontextprotocol/inspector-client` +- `@modelcontextprotocol/inspector-server` +- `@modelcontextprotocol/inspector-cli` + +**v2 publishes only the first.** It is a single tarball with a single version number, containing the web client, the CLI, the TUI, and the launcher that dispatches between them. The three sub-packages are frozen at `1.0.1`, deprecated on npm, and will never see a 2.x. + +If you depend on one of them directly, drop it and use the root package's `mcp-inspector` bin. There is no v2 equivalent of importing `inspector-server` as a library; the shared runtime lives in this repo's `core/` and is not published separately yet ([#1636](https://github.com/modelcontextprotocol/inspector/issues/1636)). + +```diff + { + "devDependencies": { +- "@modelcontextprotocol/inspector-cli": "^1.0.1" ++ "@modelcontextprotocol/inspector": "^2.0.0" + } + } +``` + +## Architecture: the proxy is gone + +v1 ran **two** processes: a React client on `6274` and an "MCP Proxy" on `6277` that held the actual MCP connections. The browser talked to the proxy over HTTP, authenticating with `MCP_PROXY_AUTH_TOKEN`. + +v2 runs **one** web server on `6274`. It serves the SPA and exposes `/api/*`, which the browser calls with `x-mcp-remote-auth: Bearer <MCP_INSPECTOR_API_TOKEN>`. The proxy port is gone: nothing needs `6277` exposed, forwarded, or allowed through a firewall. + +Consequences: + +- `SERVER_PORT` no longer selects a port you browse. (It is read as a fallback for the MCP Apps sandbox server's port — see `MCP_SANDBOX_PORT` below.) +- `MCP_PROXY_FULL_ADDRESS` has no v2 equivalent and is ignored. It existed to tell the browser where a non-default proxy lived; there is no proxy. +- Docker needs `-p 6274:6274` for the UI, where the v1 recipe published `6277` as well. + +⚠️ **`6274` is not the only listener.** The web backend also starts a **separate MCP Apps sandbox server**, on a dynamic port by default. It is only used by the Apps tab, and on plain loopback it needs no attention — but the browser reaches it directly, so anywhere the Inspector is _not_ served from the browser's own machine (Docker, a remote host, an SSH tunnel) you must pin it with `MCP_SANDBOX_PORT` and expose/forward that port too, or the Apps tab won't load. This is a different port from v1's proxy — it carries no MCP traffic — but it is still a second port. See [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). + +## Launching + +```bash +npx @modelcontextprotocol/inspector # web UI (unchanged default) +npx @modelcontextprotocol/inspector --cli # CLI +npx @modelcontextprotocol/inspector --tui # TUI (new in v2) +``` + +The mode flag must come **first**, immediately after the binary name; everything after it is forwarded to that client unchanged. + +Passing a server ad-hoc works the same way it did: + +```bash +npx @modelcontextprotocol/inspector node build/index.js +npx @modelcontextprotocol/inspector -e KEY=value -- node build/index.js --server-flag +``` + +## `--config` vs `--catalog` + +**This is the change most likely to surprise you.** Both versions have a `--config` flag, both take an `mcpServers` file, and they do not mean the same thing. + +In v1, `--config path.json --server name` resolved one entry out of the file and launched against it. The file was read once and never written — but there was no other file, because v1's web UI kept its server list in browser `localStorage`. + +v2 has a first-class **server catalog** on disk, and splits the two roles: + +| | `--catalog <path>` | `--config <path>` | +| ------------------------- | -------------------------------------------------- | -------------------------------------------------------- | +| Writable by the Inspector | **Yes** — this is the Inspector's own list | **No.** Served as-is; never written, seeded, or migrated | +| When the file is missing | created and seeded | **errors** | +| Default path | `~/.mcp-inspector/mcp.json`, or `MCP_CATALOG_PATH` | none — must be passed | +| Editable in the web UI | yes | no (catalog CRUD is hidden) | + +So: + +- **`--config` in v2 is the read-only role.** Point it at a file you didn't write — a coworker's, a client application's, one checked into a repo — and the Inspector guarantees it won't touch the bytes, including any plaintext secrets in them. This is close to v1's behavior, minus the write-back that never existed anyway. +- **`--catalog` is new** and is what you want if you'd like the web UI to add, edit, and remove servers. +- **Neither is required.** With no source flag, v2 uses the default catalog `~/.mcp-inspector/mcp.json`, creating it if absent. v1 had no default file: the CLI started with nothing unless you passed `--config`, and the web UI's list lived in the browser's `localStorage` — per-browser, and invisible to the CLI. + +### Before / after + +**Your v1 file works unchanged.** The format is the same `mcpServers` shape, `type: "streamable-http"` is still accepted (as is the `"http"` alias), and unknown fields such as v1's exported `note` are carried through rather than rejected. + +```bash +# v1 — one entry out of a file +npx @modelcontextprotocol/inspector --config ./mcp.json --server everything + +# v2 — same file, same read-only guarantee; --server selects under --cli +npx @modelcontextprotocol/inspector --cli --config ./mcp.json --server everything --method tools/list +``` + +⚠️ **`--server` only selects under `--cli`**, which is why the v2 line above gains the mode flag (and, being a CLI invocation, a `--method`). On the web client `--server` is a no-op that logs a warning and the UI loads **every** entry in the file; the TUI rejects it as an unknown option. There is no web equivalent of "open just this one entry" — you pick the server from the list after it loads. + +```bash +# v2 — let the Inspector manage the file (web UI can edit it) +npx @modelcontextprotocol/inspector --catalog ./mcp.json + +# v2 — the default catalog, no flags at all +npx @modelcontextprotocol/inspector +``` + +**Rules:** `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target (a positional command, `--server-url`, or `--transport`). v1 silently preferred the config file; v2 tells you. One exception: the **web** client exempts `--transport stdio` from that check, so it survives alongside `--catalog`/`--config` (ignored rather than rejected) — the CLI and TUI reject every `--transport`. + +If you export `MCP_CATALOG_PATH` in your shell, note that **web and TUI read it unconditionally** — so an ad-hoc invocation such as `mcp-inspector --tui node build/index.js` is rejected as a catalog/ad-hoc conflict. Unset it for that invocation. The CLI ignores the variable whenever an ad-hoc target is present. + +The full model, including the Inspector-specific per-server fields v2 adds (`protocolEra`, `roots`, `requestTimeout`, `oauth`, …), is documented in [MCP server configuration](./mcp-server-configuration.md). + +## CLI flag mapping + +Every v1 CLI flag still exists in v2 and means the same thing. Nothing was renamed or removed: + +| v1 flag | v2 | Notes | +| -------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------- | +| `--method <method>` | same | v2 adds `initialize`, `servers/list`, `servers/show`; stream-only methods are rejected explicitly | +| `--tool-name <name>` | same | | +| `--tool-arg <k=v>` | same | | +| `--uri <uri>` | same | | +| `--prompt-name <name>` | same | | +| `--prompt-args <k=v>` | same | | +| `--log-level <level>` | same | | +| `--transport <sse\|http\|stdio>` | same | | +| `--server-url <url>` | same | | +| `--header "Name: Value"` | same | | +| `--metadata <k=v>` | same | | +| `--tool-metadata <k=v>` | same | | +| `-e KEY=VALUE` | same | | +| `--config <path>` | same flag, **narrower meaning** | see [above](#--config-vs---catalog) | +| `--server <name>` | same | now `--cli`-only | +| `[target...]` | same | but see the target-ordering **and** URL-transport rules below | + +New in v2, with no v1 equivalent: + +| Flag | What it does | +| ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `--catalog <path>` | writable catalog file (see above) | +| `--cwd <path>` | working directory for a stdio server process | +| `--tool-args-json '<json>'` | tool arguments as one JSON object, passed verbatim (no `key=value` coercion, so `"012"` stays a string) | +| `--format text\|json` | `json` emits a single `{"result":…}` object on stdout with no banners | +| `--app-info` | probe a tool's MCP App UI metadata without invoking the tool | +| `--connect-timeout <ms>` | connect timeout; defaults to `15000` for ad-hoc targets so a black-holed host fails fast | +| `--client-id` / `--client-secret` / `--client-metadata-url` / `--client-config` | OAuth client configuration | +| `--callback-url <url>` | OAuth loopback redirect URI (default `http://127.0.0.1:6276/oauth/callback`) | +| `--relogin` / `--stored-auth-only` / `--use-stored-auth` / `--wait-for-auth` / `--list-stored-auth` / `--print-handoff` | OAuth token reuse and web→CLI handoff | + +v1 had no OAuth support in the CLI at all; a protected server simply failed. If you script against protected servers, `--stored-auth-only` is the flag to reach for in CI — it never opens a browser and fails fast with `auth_required` instead of hanging on a loopback callback. + +See the [CLI README](../clients/cli/README.md) for the full surface. + +## CLI behavior changes + +The flags survived; four behaviors did not. + +### 1. Exit codes and the error envelope + +v1 exited `0` on success and `1` on any failure — including a `tools/call` that came back `isError: true`, which exited `0` because the _call_ succeeded. + +v2 maps failures onto a stable set: + +| Code | Meaning | +| ---- | ------------------------------------------------------- | +| `0` | Success | +| `1` | Usage / unexpected error | +| `2` | No MCP App on the tool (`--app-info` probe) | +| `3` | Server requires authentication | +| `4` | Server unreachable (DNS, refused, timeout) | +| `5` | Tool error — `isError: true`, or the tool was not found | + +and writes one JSON line to **stderr** on any non-zero exit: + +```json +{ + "error": { + "code": "auth_required", + "message": "Unauthorized", + "status": 401, + "url": "https://api.example/mcp" + } +} +``` + +**Migration note:** a CI step that chained `inspector --cli … --method tools/call … && next-step` silently continued past failing tool calls in v1. In v2 it stops. That is the intended behavior, but it will surface as "v2 broke my pipeline" the first time a tool was failing all along. Parse the class with `2>&1 | tail -1 | jq .error` rather than matching on prose. + +### 2. The target must come first + +v2's CLI reads the leading run of non-dash tokens as the server target. Anything after the first flag is no longer part of it: + +```bash +mcp-inspector --cli node build/index.js --method tools/list # ✅ +mcp-inspector --cli --method tools/list node build/index.js # ❌ target silently dropped +``` + +The second form does **not** error — the target is discarded and the Inspector falls back to your catalog, so it appears to work against the wrong server. v1 tolerated either order. + +### 3. `--` splits the other way under `--cli` + +On **web and TUI**, everything _after_ `--` goes to the target command — same as v1: + +```bash +mcp-inspector node build/index.js -- --config /etc/myserver.conf --verbose +``` + +Under **`--cli`** it is reversed: everything _before_ `--` is the target, everything _after_ is the Inspector's own options. + +```bash +mcp-inspector --cli node build/index.js -- --method tools/list +``` + +So the web example above, run verbatim under `--cli`, would have `--config /etc/myserver.conf` consumed as the Inspector's read-only-session flag (and then rejected as a catalog/ad-hoc conflict). To pass a leading-dash argument through to a stdio server, put it **before** the `--` instead — everything on that side is forwarded to the target untouched, flags included: + +```bash +# v1 +mcp-inspector node build/index.js -- --config /etc/myserver.conf --verbose + +# v2, same thing under --cli — target and its flags first, Inspector options after +mcp-inspector --cli node build/index.js --config /etc/myserver.conf --verbose -- --method tools/list +``` + +(Without a `--` on the line, the target is only the leading run of non-dash tokens — so `--` is required whenever the server itself takes flags.) + +### 4. An ambiguous URL path no longer guesses a transport + +With no `--transport`, v2 infers it from the URL's path suffix — and **only** from that: + +| URL path | v2 | +| -------------- | ---------------------------------- | +| ends in `/mcp` | `streamable-http` | +| ends in `/sse` | `sse` | +| anything else | **error — `--transport` required** | + +``` +Transport type not specified and could not be determined from URL: <url>. +``` + +v1 fell back to SSE for an unrecognized path, so a server at e.g. `https://example.com/api` connected without a flag. In v2 the same command stops with the error above; pass `--transport http` (or `sse`) explicitly. The suffix match is exact, so a trailing slash (`…/mcp/`) is ambiguous too. + +This applies to every client's command line — CLI, TUI, and `--web` (which prints the message and exits `1`). The **browser deep link** is the one exception: `?serverUrl=…` with no `transport` param defaults to `http`. + +Stdout is otherwise compatible: the default `text` format still pretty-prints the result as `JSON.stringify(result, null, 2)`. One byte differs — v2 appends a trailing newline where v1 wrote none — so a script diffing raw stdout against a stored v1 fixture needs the fixture re-captured (or the comparison trimmed). + +## Environment variables + +| v1 | v2 | Notes | +| ------------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_PROXY_AUTH_TOKEN` | **`MCP_INSPECTOR_API_TOKEN`** | Renamed, but the **old name still works** as a deprecated fallback when the new one is unset — an existing deployment keeps running while you migrate. Guards `/api/*` via `x-mcp-remote-auth: Bearer <token>`; the browser also receives it injected into `index.html`, so a bare reload keeps working. (The `?MCP_PROXY_AUTH_TOKEN=` **query param** has no such fallback — see [Web UI](#web-ui).) | +| `MCP_PROXY_FULL_ADDRESS` | — | Removed; there is no proxy | +| `SERVER_PORT` | _repurposed_ | Was the proxy port. Now only a fallback for the MCP Apps sandbox port | +| `CLIENT_PORT` | same | Web UI port, default `6274`. Must be a fixed port — `0`/dynamic is rejected, since the origin allow-list and sandbox CSP derive from it | +| `HOST` | same, **guarded** | An all-interfaces host (`0.0.0.0`, `::`, and equivalent spellings) is now **refused** unless `DANGEROUSLY_BIND_ALL_INTERFACES=true`. Binding a specific IP or hostname needs no opt-in | +| `ALLOWED_ORIGINS` | same | Still comma-separated, still **replaces** the default list rather than merging. Entries must include the scheme | +| `DANGEROUSLY_OMIT_AUTH` | same | | +| `MCP_AUTO_OPEN_ENABLED` | same | Also gates non-TTY interactive OAuth in the CLI/TUI | +| — | `DANGEROUSLY_BIND_ALL_INTERFACES` | New opt-in for a wildcard bind (the Docker image sets it) | +| — | `MCP_CATALOG_PATH` | Default catalog path | +| — | `MCP_SANDBOX_PORT` | MCP Apps sandbox server port (dynamic by default) | +| — | `MCP_STORAGE_DIR`, `MCP_INSPECTOR_OAUTH_STATE_PATH`, `MCP_CLIENT_CONFIG_PATH` | Storage and OAuth state locations | +| — | `MCP_OAUTH_CALLBACK_URL` | CLI/TUI OAuth loopback callback | + +### The v1 UI configuration settings + +v1 exposed a **Configuration** panel writing `localStorage` keys, also settable by query param. Those were global; in v2 the equivalents are **per-server settings**, stored in the catalog entry and editable in Server Settings: + +| v1 setting | v2 | +| --------------------------------------- | ----------------------------------- | +| `MCP_SERVER_REQUEST_TIMEOUT` | per-server `requestTimeout` (ms) | +| _(no v1 equivalent)_ | per-server `connectionTimeout` (ms) | +| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | always on; no longer configurable | +| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | no equivalent | +| `MCP_PROXY_FULL_ADDRESS` | no equivalent (no proxy) | + +## Web UI + +**Server list.** v1 kept it in browser `localStorage`, so it was per-browser and invisible to the CLI. v2 keeps it in the catalog file, shared by all three clients — add a server in the web UI and the CLI and TUI see it. + +**Query params.** v1 accepted `?transport=…&serverUrl=…&serverCommand=…&serverArgs=…` plus `?MCP_PROXY_AUTH_TOKEN=…`. v2's deep link is narrower and gated: + +``` +http://127.0.0.1:6274/?serverUrl=<url>&transport=http|sse&autoConnect=<token> +``` + +- `serverCommand` / `serverArgs` are gone — a URL can no longer ask the Inspector to spawn a process. +- `serverUrl` is restricted to `http:` / `https:`. +- `autoConnect` must equal the per-launch `MCP_INSPECTOR_API_TOKEN`, so a third-party-minted link can't drive a connect. +- `?MCP_INSPECTOR_API_TOKEN=…` replaces `?MCP_PROXY_AUTH_TOKEN=…`. + +Three further params (`openApp`, `appArgs`, `autoOpen`) land you on a rendered MCP App; see the [web README](../clients/web/README.md#deep-link-auto-connect). + +**Auth.** v1's bearer-token field in the sidebar is replaced by per-server `headers` and real OAuth (including CIMD and enterprise-managed auth) configured in Server Settings and persisted in the catalog. + +## Docker + +```bash +# v1 +docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6277:6277 \ + -e HOST=0.0.0.0 -e MCP_AUTO_OPEN_ENABLED=false \ + ghcr.io/modelcontextprotocol/inspector:1.0.1 + +# v2 — no proxy port; the image already sets the wildcard-bind opt-in +docker run --rm -p 127.0.0.1:6274:6274 ghcr.io/modelcontextprotocol/inspector:latest +``` + +Notes: + +- **Keep the `127.0.0.1:` prefix on the published port**, as the v1 recipe did. `-p 6274:6274` publishes on every host interface, and the container's `HOST=0.0.0.0` is about the _container's_ interfaces, not the host's — the two are independent. That matters here because the backend spawns processes, `/` embeds the API token, and requests arriving with **no** `Origin` header (i.e. anything that isn't a browser) skip the origin allow-list, leaving the token as the only guard. Bind to loopback and opt into wider exposure deliberately. +- **The Apps tab needs one more published port.** The sandbox server is dynamic by default and the `Dockerfile` `EXPOSE`s only `6274`, so the recipe above covers everything _except_ MCP Apps. To use them, pin and publish the sandbox port as well: + + ```bash + docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6280:6280 \ + -e MCP_SANDBOX_PORT=6280 \ + ghcr.io/modelcontextprotocol/inspector:latest + ``` + +- The v2 image sets `DANGEROUSLY_BIND_ALL_INTERFACES=true` internally (a container must bind `0.0.0.0` to be reachable through `-p`). Setting a bare `HOST=0.0.0.0` **outside** a container now exits with an error. +- **If you remap the published port** (`-p 8080:6274`), the browser's origin no longer matches the in-container port, so set `ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080` (or run `-e CLIENT_PORT=8080 -p 8080:8080`) or connects will 403. +- The image runs as non-root and has a `HEALTHCHECK` that assumes `--web`; add `--no-healthcheck` when running `--cli` / `--tui`. +- `:latest` now points at v2. To stay on the v1 image, pin its exact version tag (`:1.0.1`). + +## Troubleshooting + +**"`--config` errors that my file doesn't exist, but v1 created it."** v1 never created config files either — but v2's _default_ path is a catalog it will create. If you want a file created on demand, pass it as `--catalog`, not `--config`. + +**"`--server` is ignored."** It selects only under `--cli`. Web warns and ignores it; the TUI errors on it as unknown. + +**"My CI step started failing after upgrading."** Most likely exit code `5`: a `tools/call` returning `isError: true` now exits non-zero. Check `2>&1 | tail -1 | jq .error`. + +**"The Inspector connects to a different server than I named."** Check flag order — under `--cli` the target must precede all flags, or it is silently dropped in favor of the catalog. + +**"`Transport type not specified and could not be determined from URL`."** The path ends in neither `/mcp` nor `/sse`, so v2 refuses to guess where v1 fell back to SSE. Pass `--transport http` or `--transport sse`. + +**"The Apps tab is blank in Docker / over SSH."** The MCP Apps sandbox is a second, dynamic port. Pin it with `MCP_SANDBOX_PORT` and publish/forward it too — see [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). + +**"`HOST=0.0.0.0` exits with an error."** That's the wildcard-bind guard. Bind a specific address instead, or set `DANGEROUSLY_BIND_ALL_INTERFACES=true` if you genuinely need all interfaces. See [Host binding & the origin allow-list](../clients/web/README.md#host-binding--the-origin-allow-list). + +**"I need v1 back."** `npx @modelcontextprotocol/inspector@v1-latest`. + +## Related + +- [MCP server configuration](./mcp-server-configuration.md) — the full `--catalog` / `--config` / ad-hoc model. +- [CLI README](../clients/cli/README.md) · [TUI README](../clients/tui/README.md) · [web README](../clients/web/README.md) +- [Reviewing an MCP App](./mcp-app-review.md) — the CLI-first App review recipe. diff --git a/package-lock.json b/package-lock.json index 30b8f2cbc..bb4866ac7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,21 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.1.0", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "2.1.0", + "version": "2.2.0", "hasInstallScript": true, "license": "MIT", "dependencies": { "@hono/node-server": "^2.0.12", - "@modelcontextprotocol/client": "2.0.0-beta.5", - "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/server": "2.0.0-beta.5", - "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", + "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server-legacy": "2.0.0", "@napi-rs/keyring": "^1.3.0", "@vitejs/plugin-react": "^6.0.0", "ajv": "^8.17.1", @@ -24,15 +24,13 @@ "commander": "^13.1.0", "hono": "^4.12.18", "ink": "^6.0.0", - "ink-form": "^2.0.1", - "ink-scroll-view": "^0.3.6", "open": "^10.2.0", "pino": "^9.14.0", - "react": "^19.2.4", + "react": "^19.0.0", "undici": "^8.5.0", "vite": "^8.1.5", "yaml": "^2.9.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "bin": { "mcp-inspector": "clients/launcher/build/index.js" @@ -40,6 +38,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "eslint": "^10.8.0", + "express": "^5.2.1", "globals": "^17.7.0", "prettier": "3.8.4", "typescript": "~5.9.3", @@ -300,12 +299,12 @@ } }, "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==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/core": "2.0.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", @@ -318,9 +317,9 @@ } }, "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==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", "license": "MIT", "dependencies": { "zod": "^4.2.0" @@ -330,9 +329,9 @@ } }, "node_modules/@modelcontextprotocol/ext-apps": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.4.tgz", - "integrity": "sha512-QQqysE549cf/Y0VabBmAACXhj92EhB3t8yVct2BHbkWiPTFA1S91EqTVjYXXcZEefXU0pmHcdObhsNMcomJIOQ==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.5.tgz", + "integrity": "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==", "license": "MIT", "workspaces": [ "examples/*" @@ -359,13 +358,13 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "peer": true, "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -399,26 +398,13 @@ } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/@hono/node-server": { - "version": "1.19.17", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", - "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", - "integrity": "sha512-i1E5l75rQKsgY/AKAIspgMBH1vEL7dqiK7tHr0L+raYcb0SWOziqNGJXGIG6NY4AlXDWIKGJQGB7Nqfs3oUi5g==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" }, "engines": { @@ -426,13 +412,13 @@ } }, "node_modules/@modelcontextprotocol/server-legacy": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0-beta.5.tgz", - "integrity": "sha512-8BemN4avQnG6Fu660fZCqnPGpeyL7gg5kxUceZQh7JCt8oqzX1bwJkNV+cKS01LPAaPbl93INneD+mBtJWKWvQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0.tgz", + "integrity": "sha512-LnffC1BSqFMHtMQxEz92lqDpHWma+ErV3ghdHDgdkCyYzVcCYKcUT5loq4kflty+Bf9C9qjJqbnphyBWyCqo8Q==", "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/core": "2.0.0", "content-type": "^1.0.5", "cors": "^2.8.5", "express-rate-limit": "^8.2.1", @@ -1267,7 +1253,6 @@ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", - "peer": true, "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" @@ -1418,7 +1403,6 @@ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", - "peer": true, "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", @@ -1480,7 +1464,6 @@ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -1494,7 +1477,6 @@ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", - "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -1602,7 +1584,6 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1634,7 +1615,6 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -1644,7 +1624,6 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.6.0" } @@ -1767,7 +1746,6 @@ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", - "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -1781,8 +1759,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/emoji-regex": { "version": "10.6.0", @@ -1795,7 +1772,6 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -1817,7 +1793,6 @@ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" } @@ -1827,7 +1802,6 @@ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" } @@ -1837,7 +1811,6 @@ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0" }, @@ -1846,21 +1819,21 @@ } }, "node_modules/es-toolkit": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", - "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", "license": "MIT", "workspaces": [ "docs", - "benchmarks" + "benchmarks", + "tests/types" ] }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/escape-string-regexp": { "version": "2.0.0", @@ -2068,7 +2041,6 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -2099,7 +2071,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -2209,21 +2180,6 @@ } } }, - "node_modules/figures": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", - "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", - "license": "MIT", - "dependencies": { - "is-unicode-supported": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2242,7 +2198,6 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", @@ -2302,7 +2257,6 @@ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -2312,7 +2266,6 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -2336,7 +2289,6 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2358,7 +2310,6 @@ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", - "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -2383,7 +2334,6 @@ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", - "peer": true, "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -2423,7 +2373,6 @@ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -2436,7 +2385,6 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -2449,7 +2397,6 @@ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", - "peer": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -2589,76 +2536,6 @@ } } }, - "node_modules/ink-form": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ink-form/-/ink-form-2.0.1.tgz", - "integrity": "sha512-vo0VMwHf+HOOJo7026K4vJEN8xm4sP9iWlQLx4bngNEEY5K8t30CUvVjQCCNAV6Mt2ODt2Aq+2crCuBONReJUg==", - "license": "MIT", - "dependencies": { - "ink-select-input": "^5.0.0", - "ink-text-input": "^6.0.0" - }, - "peerDependencies": { - "ink": ">=4", - "react": ">=18" - } - }, - "node_modules/ink-scroll-view": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/ink-scroll-view/-/ink-scroll-view-0.3.7.tgz", - "integrity": "sha512-lUBLxSbVry/+UtJhuvRu6wumP43+ScVp69J7e+hmYwz1kTkahWfkVwWOu7Mn1DMPb8AU8bnsmEsr6kvSJvnaRw==", - "license": "MIT", - "peerDependencies": { - "ink": "^5 || ^6 || ^7", - "react": "^18 || ^19" - } - }, - "node_modules/ink-select-input": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/ink-select-input/-/ink-select-input-6.2.0.tgz", - "integrity": "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ==", - "license": "MIT", - "dependencies": { - "figures": "^6.1.0", - "to-rotated": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "ink": ">=5.0.0", - "react": ">=18.0.0" - } - }, - "node_modules/ink-text-input": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", - "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "type-fest": "^4.18.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "ink": ">=5", - "react": ">=18" - } - }, - "node_modules/ink-text-input/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -2673,7 +2550,6 @@ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.10" } @@ -2768,20 +2644,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT", - "peer": true - }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "MIT" }, "node_modules/is-wsl": { "version": "3.1.1", @@ -3146,7 +3009,6 @@ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" } @@ -3156,7 +3018,6 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -3166,7 +3027,6 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -3179,7 +3039,6 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -3189,7 +3048,6 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", - "peer": true, "dependencies": { "mime-db": "^1.54.0" }, @@ -3262,7 +3120,6 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -3281,7 +3138,6 @@ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -3303,7 +3159,6 @@ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", - "peer": true, "dependencies": { "ee-first": "1.1.1" }, @@ -3316,7 +3171,6 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", - "peer": true, "dependencies": { "wrappy": "1" } @@ -3409,7 +3263,6 @@ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -3447,7 +3300,6 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" @@ -3592,7 +3444,6 @@ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "license": "MIT", - "peer": true, "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" @@ -3616,7 +3467,6 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "side-channel": "^1.1.0" }, @@ -3638,7 +3488,6 @@ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -3767,7 +3616,6 @@ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", @@ -3830,7 +3678,6 @@ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", @@ -3857,7 +3704,6 @@ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", - "peer": true, "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", @@ -3904,7 +3750,6 @@ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -3924,7 +3769,6 @@ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" @@ -3941,7 +3785,6 @@ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -3960,7 +3803,6 @@ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -4046,9 +3888,9 @@ } }, "node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", "license": "MIT", "dependencies": { "get-east-asian-width": "^1.5.0", @@ -4140,18 +3982,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/to-rotated": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/to-rotated/-/to-rotated-1.0.0.tgz", - "integrity": "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -4195,9 +4025,9 @@ } }, "node_modules/type-fest": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", - "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -4214,7 +4044,6 @@ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", - "peer": true, "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", @@ -4460,13 +4289,12 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -4534,9 +4362,9 @@ "license": "MIT" }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 2191a0193..4e0295acd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.1.0", + "version": "2.2.0", "description": "The Model Context Protocol Inspector", "keywords": [ "MCP", @@ -24,6 +24,7 @@ "clients/launcher/build", "clients/web/build", "clients/web/dist", + "clients/web/static", "clients/cli/build", "clients/tui/build", "scripts/install-clients.mjs" @@ -42,8 +43,9 @@ "verify:build-gate": "node scripts/verify-build-gate.mjs", "verify:typecheck-coverage": "node scripts/verify-typecheck-coverage.mjs", "test:scripts": "node --test \"scripts/**/*.test.mjs\"", - "validate": "npm run verify:format-coverage && npm run verify:typecheck-coverage && npm run test:scripts && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher", + "validate": "npm run verify:format-coverage && npm run verify:typecheck-coverage && npm run verify:dep-lockstep && npm run test:scripts && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher", "verify:format-coverage": "node scripts/verify-format-coverage.mjs", + "verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs", "validate:core": "npm run format:check:core && npm run format:check:scripts && npm run format:check:shared && npm run lint:core && npm run lint:shared", "lint:core": "eslint \"core/**/*.{ts,tsx}\"", "lint:shared": "eslint \"test-servers/src/**/*.{ts,tsx,mts,cts}\" vitest.shared.mts eslint.config.js", @@ -63,11 +65,12 @@ "coverage:tui": "cd clients/tui && npm run test:coverage", "coverage:web": "cd clients/web && npm run test:coverage", "coverage:launcher": "cd clients/launcher && npm run test:coverage", - "smoke": "npm run smoke:launcher && npm run smoke:cli && npm run smoke:tui && npm run smoke:web && npm run smoke:web:browser", + "smoke": "npm run smoke:launcher && npm run smoke:cli && npm run smoke:tui && npm run smoke:web && npm run smoke:web:browser && npm run smoke:web:app", "smoke:cli": "node scripts/smoke-cli.mjs", "smoke:tui": "node scripts/smoke-tui.mjs", "smoke:web": "node scripts/smoke-web.mjs", "smoke:web:browser": "cd clients/web && npx playwright install chromium && node ../../scripts/smoke-web-browser.mjs", + "smoke:web:app": "cd clients/web && npx playwright install chromium && node ../../scripts/smoke-web-app.mjs", "smoke:launcher": "node scripts/smoke-launcher.mjs", "pack:verify": "node scripts/pack-and-verify.mjs", "prepack": "npm run build", @@ -75,11 +78,11 @@ }, "dependencies": { "@hono/node-server": "^2.0.12", - "@modelcontextprotocol/client": "2.0.0-beta.5", - "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/server": "2.0.0-beta.5", - "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", + "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server-legacy": "2.0.0", "@napi-rs/keyring": "^1.3.0", "@vitejs/plugin-react": "^6.0.0", "ajv": "^8.17.1", @@ -88,18 +91,13 @@ "commander": "^13.1.0", "hono": "^4.12.18", "ink": "^6.0.0", - "ink-form": "^2.0.1", - "ink-scroll-view": "^0.3.6", "open": "^10.2.0", "pino": "^9.14.0", - "react": "^19.2.4", + "react": "^19.0.0", "undici": "^8.5.0", "vite": "^8.1.5", "yaml": "^2.9.0", - "zod": "^4.3.6" - }, - "overrides": { - "ink-select-input": "^6.2.0" + "zod": "^4.4.3" }, "engines": { "node": ">=22.19.0" @@ -107,6 +105,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "eslint": "^10.8.0", + "express": "^5.2.1", "globals": "^17.7.0", "prettier": "3.8.4", "typescript": "~5.9.3", diff --git a/scripts/lib/prod-web-server.mjs b/scripts/lib/prod-web-server.mjs index 254410efd..895d95093 100644 --- a/scripts/lib/prod-web-server.mjs +++ b/scripts/lib/prod-web-server.mjs @@ -1,10 +1,12 @@ /** * Shared boot/readiness helper for the prod web smokes. * - * Both `scripts/smoke-web.mjs` (serves-the-HTML check) and - * `scripts/smoke-web-browser.mjs` (runs-the-bundle check, #1615) boot the *same* - * prod `mcp-inspector --web` server, so the spawn + readiness-poll boilerplate - * lives here once instead of being copy-pasted (and drifting) in each script. + * `scripts/smoke-web.mjs` (serves-the-HTML check), `scripts/smoke-web-browser.mjs` + * (runs-the-bundle check, #1615), and `scripts/smoke-web-app.mjs` (MCP Apps + * end-to-end, #1859) all boot the *same* prod `mcp-inspector --web` server, so the + * spawn + readiness-poll boilerplate lives here once instead of being copy-pasted + * (and drifting) in each script. Catalog isolation (#1977) lives here for the same + * reason — it is a property every web smoke needs, not one script's concern. * * Repo-root paths are derived from import.meta.url, so a caller's cwd (e.g. * `smoke:web:browser` does `cd clients/web` first so its `npx playwright @@ -12,35 +14,136 @@ */ import { spawn } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; -import { dirname, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; + +import { removeSafe, stopChild } from "./child-cleanup.mjs"; const libDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(libDir, "..", ".."); const launcherEntry = resolve(repoRoot, "clients/launcher/build/index.js"); +/** + * Mint a fresh throwaway catalog for one server run. + * + * `mkdtemp` (not a fixed name) is what makes concurrent runs safe: the three web + * smokes run back-to-back today, but a fixed path would have them share — and + * silently reintroduce the cross-run bleed this whole change removes. + * + * The catalog *file* is deliberately not created. The backend seeds an empty + * catalog on first use, which is exactly the first-run state we want every run + * to start from. + * + * @returns {{ dir: string, path: string }} + */ +export function createTempCatalog() { + const dir = mkdtempSync(join(tmpdir(), "smoke-web-catalog-")); + return { dir, path: join(dir, "catalog.json") }; +} + +/** + * Build the child env for the prod web server. + * + * Split out from the spawn so the isolation contract is unit-testable without + * starting a server (#1977). The ordering is load-bearing: `MCP_CATALOG_PATH` + * is assigned *after* the `process.env` spread, so an inherited value from the + * developer's shell is overridden rather than silently winning — which would + * put the smoke straight back on whatever catalog that variable names. + * + * @param {object} opts + * @param {string} opts.host + * @param {string} opts.port + * @param {string} opts.token + * @param {string} opts.catalogPath + * @param {NodeJS.ProcessEnv} [opts.baseEnv] + */ +export function buildWebServerEnv({ + host, + port, + token, + catalogPath, + baseEnv = process.env, +}) { + return { + ...baseEnv, + CLIENT_PORT: port, + HOST: host, + MCP_INSPECTOR_API_TOKEN: token, + MCP_CATALOG_PATH: catalogPath, + // Don't pop a browser in CI. + MCP_AUTO_OPEN_ENABLED: "false", + }; +} + +/** + * Terminate a spawned web server, then remove its catalog dir. + * + * The two halves are the documented pair in `child-cleanup.mjs`, and both are + * needed. `stopChild` closes the #1801 race on the normal path (a bare `kill()` + * only *delivers* the signal, so a synchronous remove can hit ENOTEMPTY when the + * server writes the catalog on its way out); `removeSafe` then makes the + * residual case harmless, warning instead of throwing so a leftover temp dir can + * never turn a passing smoke red. + * + * Exported separately from `startProdWebServer` so the teardown *contract* is + * testable against a stand-in child, without booting a real launcher. That split + * is what makes the leak detectable at all: the smokes exit immediately after + * teardown, so deleting the `removeSafe` call below would leave all three of them + * green. The focused unit tests in `prod-web-server.test.mjs` are the only thing + * that fails on it. + * + * @param {object} opts + * @param {import("node:child_process").ChildProcess} opts.child + * @param {string} opts.catalogDir + * @param {string} [opts.label] + */ +export async function teardownWebServer({ + child, + catalogDir, + label = "smoke:web", +}) { + await stopChild(child, { label, what: "prod web server" }); + removeSafe(catalogDir, { label }); +} + /** * Spawn `mcp-inspector --web` (prod, no `--dev`) against the built * `clients/web/dist` and return handles for readiness + teardown. * + * The server always runs against a **throwaway catalog**, never the developer's + * real `~/.mcp-inspector/mcp.json` (#1977). Without `MCP_CATALOG_PATH` the web + * backend falls back to that default writable catalog, which made these smokes + * both destructive and non-deterministic: `smoke:web:app`'s deep link persists a + * `deep-link` server row, so a second run finds it already on disk. The row is + * then racing hydration — the deep-link effect misses it in `servers`, POSTs + * `addServer` anyway, and the backend answers 409. The app swallows that 409 by + * design so the smoke still passes, but it reports a spurious non-fatal console + * error that is really just residue from the previous run. Isolating the catalog + * makes every run look like the first one, and mirrors `smoke:cli` / `smoke:tui`, + * which have always driven a temp `--catalog`. + * + * Only the catalog is redirected. Other per-user state under `~/.mcp-inspector` + * (OAuth tokens, the `storage/` dir) is still shared — isolating that means + * redirecting HOME wholesale, which these smokes deliberately do not do, since + * HOME also resolves the npx and Playwright caches they depend on. + * * @param {object} opts * @param {string} opts.host * @param {string} opts.port * @param {string} opts.token value injected as MCP_INSPECTOR_API_TOKEN + * @param {string} [opts.label] prefix for teardown warnings (the temp dir's own + * prefix is fixed, so every run's dir is greppable as `smoke-web-catalog-*`) */ -export function startProdWebServer({ host, port, token }) { +export function startProdWebServer({ host, port, token, label = "smoke:web" }) { const baseUrl = `http://${host}:${port}`; + const { dir: catalogDir, path: catalogPath } = createTempCatalog(); + const child = spawn(process.execPath, [launcherEntry, "--web"], { - env: { - ...process.env, - CLIENT_PORT: port, - HOST: host, - MCP_INSPECTOR_API_TOKEN: token, - // Don't pop a browser in CI. - MCP_AUTO_OPEN_ENABLED: "false", - }, + env: buildWebServerEnv({ host, port, token, catalogPath }), stdio: ["ignore", "inherit", "inherit"], }); @@ -119,10 +222,10 @@ export function startProdWebServer({ host, port, token }) { return { baseUrl, + catalogPath, waitForReady, whenChildExits, - stop: () => { - if (!exited) child.kill("SIGTERM"); - }, + /** Terminate the server, then remove its catalog dir. **Await this.** */ + stop: () => teardownWebServer({ child, catalogDir, label }), }; } diff --git a/scripts/lib/prod-web-server.test.mjs b/scripts/lib/prod-web-server.test.mjs new file mode 100644 index 000000000..0888d887f --- /dev/null +++ b/scripts/lib/prod-web-server.test.mjs @@ -0,0 +1,163 @@ +/** + * Unit tests for the prod-web-server helper's catalog-isolation contract (#1977). + * + * The smokes themselves cannot guard this: they exercise startup and then exit + * the process immediately after teardown, so a regression that reintroduced the + * shared catalog — or that stopped cleaning up — would still leave every smoke + * green. These lock down the three properties that make isolation real: each run + * gets its own catalog, an inherited `MCP_CATALOG_PATH` cannot win, and the temp + * dir is removable on stop. + * + * `startProdWebServer` itself is not unit-tested — it spawns a real launcher, so + * its contract is the smokes' job. What is tested here is exactly the part that + * decides *which catalog the server sees*, which is pure. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { existsSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +import { + buildWebServerEnv, + createTempCatalog, + teardownWebServer, +} from "./prod-web-server.mjs"; +import { hasExited, removeSafe } from "./child-cleanup.mjs"; + +const BASE = { + host: "127.0.0.1", + port: "6299", + token: "test-token", + catalogPath: "/tmp/example/catalog.json", +}; + +test("createTempCatalog: mints a unique dir per call", () => { + const a = createTempCatalog(); + const b = createTempCatalog(); + try { + assert.notEqual(a.dir, b.dir, "two runs must not share a catalog dir"); + assert.equal(dirname(a.path), a.dir); + assert.ok(existsSync(a.dir)); + assert.ok(existsSync(b.dir)); + } finally { + removeSafe(a.dir); + removeSafe(b.dir); + } +}); + +test("createTempCatalog: does not create the catalog file itself", () => { + // The backend seeds an empty catalog on first use; pre-creating the file would + // hand it a zero-byte file to parse instead of the clean first-run state. + const { dir, path } = createTempCatalog(); + try { + assert.equal(existsSync(path), false); + } finally { + removeSafe(dir); + } +}); + +/** + * A stand-in for the web server: a real child process that ignores nothing and + * simply stays alive until signalled, so `teardownWebServer` exercises its true + * SIGTERM→exit path rather than a mock's idea of one. + */ +function spawnIdleChild() { + return spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + stdio: "ignore", + }); +} + +test("teardownWebServer: removes the catalog dir it was given", async () => { + // The regression this guards: dropping the `removeSafe` call from the teardown + // would leave every smoke green, since they exit right after teardown and so + // never observe the leak. Assert on the dir itself, not on a spy. + const child = spawnIdleChild(); + const { dir, path } = createTempCatalog(); + writeFileSync(path, "{}"); + assert.ok(existsSync(dir)); + + await teardownWebServer({ child, catalogDir: dir, label: "test" }); + + assert.equal(existsSync(dir), false, "teardown must remove the catalog dir"); +}); + +test("teardownWebServer: the catalog outlives the child", async () => { + // The #1801 ordering, pinned at the one instant that distinguishes it. Simply + // asserting `hasExited` after the await is too weak — that also passes if the + // dir were removed *first*, or while an un-awaited stopChild was still pending. + // So sample the dir from the child's own `exit` handler: registering it before + // teardown puts it ahead of stopChild's listener, so it observes the world at + // the moment the child dies. removeSafe cannot have run yet — it is sequenced + // after the promise stopChild resolves from this very event. + const child = spawnIdleChild(); + const { dir } = createTempCatalog(); + assert.equal(hasExited(child), false, "child should start alive"); + + let dirAtChildExit = null; + child.once("exit", () => { + dirAtChildExit = existsSync(dir); + }); + + await teardownWebServer({ child, catalogDir: dir, label: "test" }); + + assert.equal( + dirAtChildExit, + true, + "catalog must still exist when the child exits — removing earlier is the #1801 race", + ); + assert.ok(hasExited(child), "teardown must await the child's exit"); + assert.equal(existsSync(dir), false, "and must remove it afterwards"); +}); + +test("teardownWebServer: an already-dead child is not an error", async () => { + // The failure path: a smoke calls fail() after the launcher already crashed. + // Teardown still has to clean up rather than hang on an exit that never comes. + const child = spawnIdleChild(); + child.kill("SIGKILL"); + await once(child, "exit"); + const { dir } = createTempCatalog(); + + await teardownWebServer({ child, catalogDir: dir, label: "test" }); + + assert.equal(existsSync(dir), false); +}); + +test("buildWebServerEnv: points the server at the given catalog", () => { + const env = buildWebServerEnv({ ...BASE, baseEnv: {} }); + assert.equal(env.MCP_CATALOG_PATH, BASE.catalogPath); +}); + +test("buildWebServerEnv: overrides an inherited MCP_CATALOG_PATH", () => { + // The regression this guards: assigning before the spread would let a + // developer's exported MCP_CATALOG_PATH win, putting the smoke back on a + // real catalog while every test still passed. + const env = buildWebServerEnv({ + ...BASE, + baseEnv: { MCP_CATALOG_PATH: "/home/dev/.mcp-inspector/mcp.json" }, + }); + assert.equal(env.MCP_CATALOG_PATH, BASE.catalogPath); +}); + +test("buildWebServerEnv: passes through unrelated inherited vars", () => { + const env = buildWebServerEnv({ ...BASE, baseEnv: { PATH: "/usr/bin" } }); + assert.equal(env.PATH, "/usr/bin"); +}); + +test("buildWebServerEnv: sets host, port, token, and disables auto-open", () => { + const env = buildWebServerEnv({ ...BASE, baseEnv: {} }); + assert.equal(env.HOST, BASE.host); + assert.equal(env.CLIENT_PORT, BASE.port); + assert.equal(env.MCP_INSPECTOR_API_TOKEN, BASE.token); + assert.equal(env.MCP_AUTO_OPEN_ENABLED, "false"); +}); + +test("buildWebServerEnv: an inherited auto-open setting cannot re-enable it", () => { + const env = buildWebServerEnv({ + ...BASE, + baseEnv: { MCP_AUTO_OPEN_ENABLED: "true" }, + }); + assert.equal(env.MCP_AUTO_OPEN_ENABLED, "false"); +}); diff --git a/scripts/pack-and-verify.mjs b/scripts/pack-and-verify.mjs index c19bbfe4d..ff1dd310e 100644 --- a/scripts/pack-and-verify.mjs +++ b/scripts/pack-and-verify.mjs @@ -22,8 +22,8 @@ * * 1. builds every client (`npm run build`); * 2. packs the publishable tarball (`npm pack`) and inspects its file list — - * asserting NO source maps ship and that `clients/web/{build,dist}` are - * both present (the two packaging fixes this work landed); + * asserting NO source maps ship and that `clients/web/{build,dist,static}` + * are all present (the packaging fixes this work landed); * 3. installs that tarball into a fresh temp dir (real `npm install <tgz>`, * which runs the package's `postinstall`); * 4. runs the installed `mcp-inspector` bin: `--help`, `--cli`/`--tui` help @@ -164,26 +164,31 @@ if (maps.length > 0) { } // 2b. Runtime files that are easy to omit from the packlist and only fail once -// installed: both web artifacts — the prod server runner (build) AND the SPA -// (dist). `clients/web/build` was previously dropped by the nested -// .gitignore. (The version the CLI/TUI report is read from the root -// package.json — always shipped — via readInspectorVersion(), so no client -// package.json needs to ship; that read is exercised by driving the bin in -// step 4.) +// installed: the web artifacts — the prod server runner (build), the SPA +// (dist), and the MCP Apps sandbox proxy page (static). `clients/web/build` +// was previously dropped by the nested .gitignore; `clients/web/static` was +// never listed in the root "files" allowlist at all, so the Apps tab failed +// with "Sandbox not loaded" on every published build (#1859). None of these +// are checked-in-tree failures — only an installed tarball reveals them. +// (The version the CLI/TUI report is read from the root package.json — +// always shipped — via readInspectorVersion(), so no client package.json +// needs to ship; that read is exercised by driving the bin in step 4.) for (const required of [ "clients/web/build/index.js", "clients/web/dist/index.html", + "clients/web/static/sandbox_proxy.html", ]) { if (!tarredPaths.includes(required)) { fail( `expected \`${required}\` in the published tarball but it is missing — ` + - `check the "files" field in clients/web/package.json`, + `check the "files" field in the root package.json (and that ` + + `clients/web/.npmignore does not exclude it)`, ); } } console.log( `pack:verify — tarball OK: ${tarredPaths.length} files, no source maps, ` + - `clients/web/{build,dist} present (${(packInfo.unpackedSize / 1048576).toFixed(2)} MB unpacked)`, + `clients/web/{build,dist,static} present (${(packInfo.unpackedSize / 1048576).toFixed(2)} MB unpacked)`, ); // --------------------------------------------------------------------------- @@ -229,10 +234,14 @@ try { if (!existsSync(bin)) { fail(`installed \`mcp-inspector\` bin not found at ${bin}`); } - // Confirm the two packaging fixes survived install onto disk. + // Confirm the packaging fixes survived install onto disk. The sandbox proxy + // is resolved at runtime as `<build>/../static/sandbox_proxy.html`, so its + // position *relative to* clients/web/build is what matters, not just presence + // in the tarball (#1859). for (const required of [ join(installedPkg, "clients", "web", "build", "index.js"), join(installedPkg, "clients", "web", "dist", "index.html"), + join(installedPkg, "clients", "web", "static", "sandbox_proxy.html"), join(installedPkg, "clients", "launcher", "build", "index.js"), ]) { if (!existsSync(required)) { diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs new file mode 100644 index 000000000..c5f2f8a60 --- /dev/null +++ b/scripts/smoke-web-app.mjs @@ -0,0 +1,358 @@ +#!/usr/bin/env node +/** + * Headless-browser MCP Apps smoke for the prod web client (#1859). + * + * `smoke:web:browser` proves the bundle boots and paints its first frame. It + * stops there — it never connects to a server, so everything downstream of the + * connect (the Apps tab, the sandbox controller, the UI-protocol bridge) is + * unexercised by any smoke. This closes that gap: it drives the full + * **connect → open app → widget ready** chain against a real MCP App server. + * + * The assertion is the `data-app-status="ready"` contract documented in + * clients/web/README.md ("MCP Apps screen automation contract"): the renderer + * only reports `ready` once the widget has loaded inside the sandbox iframe and + * fired `notifications/initialized` back through the bridge. So a single + * attribute covers the whole path — sandbox controller serving the proxy page, + * the proxy loading the UI resource, and the bridge completing its handshake. + * + * ── What this does and does NOT catch ─────────────────────────────────────── + * + * This runs against the **repo build tree**, like every other `smoke:*`. That + * matters for the bug that motivated it: #1859 was a *packaging* failure — + * `clients/web/static/sandbox_proxy.html` was missing from the published + * tarball's "files" allowlist. In the repo that file is always present, so this + * smoke would have stayed green through that entire bug. + * + * The packaging dimension is owned by `npm run pack:verify`, which asserts the + * file both in the tarball packlist and on disk after a real install. Keep both: + * pack:verify proves the file *ships*, this proves the App path *works*. Neither + * subsumes the other, and the failure this one is positioned to catch is a + * regression in the sandbox/bridge code itself — which pack:verify, driving only + * `GET /`, would not notice. + * + * As a cheap extra, this does assert the proxy page exists at the location the + * runtime resolves it from (`clients/web/build/../static/…`, see + * server/sandbox-controller.ts) — which catches the file being *moved or + * renamed* without its reader being updated, a repo-tree failure pack:verify + * would only find later. + * + * Playwright is resolved with a `createRequire` based at clients/web/package.json + * rather than a bare `import("playwright")` — a bare ESM specifier resolves + * relative to scripts/, not the cwd, so `cd clients/web` in the npm script would + * NOT make it resolvable. Same gotcha as smoke:web:browser; see its header. + * + * Expects `clients/web/dist` and `clients/launcher/build` to be built first — + * the validate / CI ordering guarantees this. `test-servers/build` is built on + * demand if missing, as in smoke:cli. + */ + +import { spawn, spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { setTimeout as delay } from "node:timers/promises"; +import { join, resolve } from "node:path"; +import { startProdWebServer } from "./lib/prod-web-server.mjs"; +import { stopChild } from "./lib/child-cleanup.mjs"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const requireFromWeb = createRequire( + resolve(repoRoot, "clients/web/package.json"), +); + +const composableServer = join( + repoRoot, + "test-servers", + "build", + "server-composable.js", +); +const appConfig = join( + repoRoot, + "test-servers", + "configs", + "mcp-app-http.json", +); +// The path clients/web/server/sandbox-controller.ts resolves at runtime, from +// the built runner at clients/web/build/. Kept in sync with the `join(__dirname, +// "../static/sandbox_proxy.html")` there. +const sandboxProxyPage = join( + repoRoot, + "clients", + "web", + "static", + "sandbox_proxy.html", +); + +const HOST = "127.0.0.1"; +// Distinct from smoke:web (6299) and smoke:web:browser (6298) so a prior smoke +// whose port is still bound — slow teardown, TIME_WAIT, or a parallel run — +// can't EADDRINUSE this one. The three run back-to-back in `npm run smoke`. +const PORT = process.env.SMOKE_WEB_APP_PORT ?? "6297"; +const TOKEN = "smoke-web-app-token"; +const APP_TOOL = "mcp_app_demo"; +// Console messages that are the async half of the uncaught-crash class (an +// unhandled rejection or a failed dynamic import). Hard failures; every other +// console error is a diagnostic, so benign font-CDN / React-warning noise can't +// flake CI. Kept identical to smoke-web-browser.mjs, which documents the +// reasoning at length. +const FATAL_CONSOLE = /^Uncaught\b|Failed to fetch dynamically imported module/; +// The URL the test server announces on startup. NOT derived from the config's +// port: createTestServerHttp resolves its port with findAvailablePort(), which +// walks UPWARD from the configured value when it's taken — so the config port is +// a starting hint, not a guarantee, and assuming it makes this smoke fail +// whenever anything else holds that port. The announced line is authoritative. +let mcpUrl = null; + +let mcpServer = null; +let browser = null; +const server = startProdWebServer({ + host: HOST, + port: PORT, + token: TOKEN, + label: "smoke:web:app", +}); + +async function shutdown() { + if (browser) { + try { + await browser.close(); + } catch { + // best-effort + } + browser = null; + } + await server.stop(); + if (mcpServer) { + const child = mcpServer; + mcpServer = null; + await stopChild(child, { label: "smoke:web:app", what: "MCP test server" }); + } +} + +async function fail(message) { + console.error(`smoke:web:app FAILED — ${message}`); + await shutdown(); + process.exit(1); +} + +/** Build the composable test server bundle if it isn't present yet. */ +function ensureTestServer() { + if (existsSync(composableServer)) return; + console.log( + "smoke:web:app — building test-servers (missing build output)...", + ); + const r = spawnSync("npx", ["tsc", "-p", "test-servers", "--noCheck"], { + cwd: repoRoot, + stdio: "inherit", + }); + if (r.status !== 0 || !existsSync(composableServer)) { + throw new Error( + "could not build the test servers (test-servers/build/server-composable.js). " + + "Run `npm run test-servers:build` from clients/web.", + ); + } +} + +/** + * Spawn the MCP App test server and wait for it to announce its URL. + * + * Both stdio channels are piped and scanned: server-composable.ts announces + * readiness with `console.error`, so watching stdout alone never matches and + * this times out with an empty diagnostic. Piping both also keeps the child's + * noise out of the smoke's own output while still making it available in the + * failure message. + */ +async function startMcpServer() { + const child = spawn( + process.execPath, + [composableServer, "--config", appConfig], + { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] }, + ); + let out = ""; + child.stdout.on("data", (d) => (out += d)); + child.stderr.on("data", (d) => (out += d)); + let exited = false; + let spawnError = null; + // A spawn failure (e.g. an unbuilt/renamed entry) emits `error`, NOT `exit` — + // and with no `error` listener Node throws it uncaught, replacing this smoke's + // diagnostic with a raw stack. `close` is listened to alongside `exit` for the + // same reason: it fires in cases `exit` does not, so a child that dies without + // an exit event can't leave the poll below spinning for the full 30s. + child.on("error", (err) => (spawnError = err)); + child.on("exit", () => (exited = true)); + child.on("close", () => (exited = true)); + + for (let attempt = 0; attempt < 120; attempt++) { + // Take the port the server actually bound, not the one we asked for. + const announced = out.match(/listening at (http:\/\/\S+)/i); + if (announced) return { child, url: announced[1] }; + if (spawnError) { + throw new Error( + `could not spawn the MCP test server (${composableServer}): ${spawnError.message}`, + ); + } + if (exited) throw new Error(`MCP test server exited early:\n${out}`); + await delay(250); + } + throw new Error(`MCP test server did not start within 30s:\n${out}`); +} + +/** base64url(JSON) — the appArgs encoding the deep link expects. */ +function encodeAppArgs(args) { + return Buffer.from(JSON.stringify(args)) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +async function loadChromium() { + let chromium; + try { + ({ chromium } = requireFromWeb("playwright")); + } catch (err) { + // Not resolvable means devDependencies are missing — fixed by `npm install` + // at the repo root, NOT by `playwright install` (which fetches binaries). + throw new Error( + `could not resolve the Playwright package from clients/web — run \`npm install\` at the repo root (${err instanceof Error ? err.message : String(err)})`, + ); + } + try { + return await chromium.launch({ headless: true }); + } catch (err) { + throw new Error( + `chromium failed to launch — on a bare Linux box run \`npx playwright install --with-deps chromium\` for the system libraries (${err instanceof Error ? err.message : String(err)})`, + ); + } +} + +try { + // Cheap structural check first: the sandbox proxy page must exist where the + // runtime looks for it. Fails fast with a clear cause instead of surfacing as + // an opaque "app never reached ready" 30s timeout below. + if (!existsSync(sandboxProxyPage)) { + await fail( + `sandbox proxy page missing at ${sandboxProxyPage} — clients/web/server/sandbox-controller.ts ` + + `reads it as \`join(__dirname, "../static/sandbox_proxy.html")\`; if it moved, update both ` + + `(and the "files" allowlist in the root package.json, see #1859)`, + ); + } + + ensureTestServer(); + ({ child: mcpServer, url: mcpUrl } = await startMcpServer()); + await server.waitForReady(); + browser = await loadChromium(); + const page = await browser.newPage(); + + // Uncaught *synchronous* page errors. Their *async* twin — an unhandled + // rejection or a failed dynamic import — is not a `pageerror`; Chromium + // reports it on the console channel instead, so both are captured and both + // are hard failures. Same split as smoke:web:browser; see FATAL_CONSOLE there. + const pageErrors = []; + const consoleErrors = []; + page.on("pageerror", (err) => + pageErrors.push(err instanceof Error ? err.message : String(err)), + ); + page.on("console", (msg) => { + if (msg.type() === "error") consoleErrors.push(msg.text()); + }); + const fatalConsole = () => consoleErrors.filter((m) => FATAL_CONSOLE.test(m)); + + // Deep link: connect, switch to the Apps tab, pre-select the app tool, and + // fire "Open App". autoConnect/autoOpen must equal the session token (CSRF + // gate). Shape owned by clients/web/README.md#deep-link-auto-connect. + const url = + `${server.baseUrl}/?serverUrl=${encodeURIComponent(mcpUrl)}` + + `&transport=http&autoConnect=${TOKEN}&openApp=${APP_TOOL}` + + `&appArgs=${encodeAppArgs({ title: "smoke:web:app" })}&autoOpen=${TOKEN}`; + + const drive = async () => { + const response = await page.goto(url, { + waitUntil: "domcontentloaded", + timeout: 30_000, + }); + if (!response || !response.ok()) { + throw new Error( + `GET / returned HTTP ${response ? response.status() : "no response"}`, + ); + } + + // 1. The deep link must be accepted (not rejected by the token gate). + const status = page.locator('[data-testid="connection-status"]'); + await status.waitFor({ state: "attached", timeout: 30_000 }); + const deeplink = await status.getAttribute("data-deeplink"); + if (deeplink !== "parsed") { + throw new Error( + `deep link was not accepted (data-deeplink="${deeplink}") — expected "parsed"`, + ); + } + + // 2. Connected to the test server. + await page + .locator('[data-testid="connection-status"][data-status="connected"]') + .waitFor({ state: "attached", timeout: 45_000 }); + + // 3. The widget rendered inside the sandbox and completed its handshake. + // This is the load-bearing assertion — see the header comment. + try { + await page + .locator('[data-testid="apps-form"][data-app-status="ready"]') + .waitFor({ state: "attached", timeout: 45_000 }); + } catch { + const form = page.locator('[data-testid="apps-form"]'); + const appStatus = (await form.count()) + ? await form.getAttribute("data-app-status") + : "(no apps-form)"; + const appError = (await form.count()) + ? await form.getAttribute("data-app-error") + : null; + throw new Error( + `app never reached data-app-status="ready" (last: "${appStatus}"` + + `${appError ? `, data-app-error="${appError}"` : ""}) — the sandbox proxy ` + + `or the UI-protocol bridge failed to complete`, + ); + } + }; + + // Race against launcher death so a mid-run server crash is reported as the + // real cause instead of a downstream timeout. + try { + await Promise.race([server.whenChildExits(), drive()]); + } catch (err) { + const diagnostics = [ + ...pageErrors, + ...fatalConsole().map((m) => `console: ${m}`), + ]; + await fail( + `${err instanceof Error ? err.message : String(err)}${ + diagnostics.length + ? ` — page diagnostics: ${diagnostics.join("; ")}` + : "" + }`, + ); + } + + // Hard failures: any uncaught sync page error, plus the console errors that + // are the async half of the same class. + const fatal = [...pageErrors, ...fatalConsole()]; + if (fatal.length > 0) { + await fail(`app logged uncaught error(s): ${fatal.join("; ")}`); + } + + // Non-fatal console errors: surface them so a real problem isn't invisible, + // without failing on benign subresource/warning noise. + const benignConsole = consoleErrors.filter((m) => !FATAL_CONSOLE.test(m)); + if (benignConsole.length > 0) { + console.log( + `smoke:web:app note — ${benignConsole.length} non-fatal console error(s): ${benignConsole.join("; ")}`, + ); + } + + console.log( + `smoke:web:app OK — connected to ${mcpUrl}, opened "${APP_TOOL}", ` + + `widget reached data-app-status="ready" through the sandbox proxy`, + ); + await shutdown(); + process.exit(0); +} catch (err) { + await fail(err instanceof Error ? err.message : String(err)); +} diff --git a/scripts/smoke-web-browser.mjs b/scripts/smoke-web-browser.mjs index 1188865cf..ade88c547 100644 --- a/scripts/smoke-web-browser.mjs +++ b/scripts/smoke-web-browser.mjs @@ -78,7 +78,12 @@ const TOKEN = "smoke-web-browser-token"; // the font/CDN flake. const FATAL_CONSOLE = /^Uncaught\b|Failed to fetch dynamically imported module/; -const server = startProdWebServer({ host: HOST, port: PORT, token: TOKEN }); +const server = startProdWebServer({ + host: HOST, + port: PORT, + token: TOKEN, + label: "smoke:web:browser", +}); let browser = null; async function shutdown() { @@ -90,7 +95,7 @@ async function shutdown() { } browser = null; } - server.stop(); + await server.stop(); } async function fail(message) { diff --git a/scripts/smoke-web.mjs b/scripts/smoke-web.mjs index 143ba925d..867ae4ffd 100644 --- a/scripts/smoke-web.mjs +++ b/scripts/smoke-web.mjs @@ -30,33 +30,41 @@ const TOKEN = "smoke-web-token"; // literal because this plain .mjs script can't import the TS source. const TOKEN_GLOBAL = "__INSPECTOR_API_TOKEN__"; -const server = startProdWebServer({ host: HOST, port: PORT, token: TOKEN }); +const server = startProdWebServer({ + host: HOST, + port: PORT, + token: TOKEN, + label: "smoke:web", +}); -function fail(message) { +// Async because `server.stop()` awaits the child's exit before removing its temp +// catalog — so every call site must `await fail(...)`, or execution would run on +// past it instead of exiting. +async function fail(message) { console.error(`smoke:web FAILED — ${message}`); - server.stop(); + await server.stop(); process.exit(1); } try { const res = await server.waitForReady(); if (res.status !== 200) { - fail(`GET / returned HTTP ${res.status}, expected 200`); + await fail(`GET / returned HTTP ${res.status}, expected 200`); } const body = await res.text(); if (!body.includes(TOKEN_GLOBAL)) { - fail( + await fail( `served HTML is missing the ${TOKEN_GLOBAL} global (token not injected)`, ); } if (!body.includes(TOKEN)) { - fail("served HTML is missing the injected auth-token value"); + await fail("served HTML is missing the injected auth-token value"); } console.log( `smoke:web OK — GET / => 200 with injected ${TOKEN_GLOBAL} at ${server.baseUrl}`, ); - server.stop(); + await server.stop(); process.exit(0); } catch (err) { - fail(err instanceof Error ? err.message : String(err)); + await fail(err instanceof Error ? err.message : String(err)); } diff --git a/scripts/verify-dep-lockstep.main.test.mjs b/scripts/verify-dep-lockstep.main.test.mjs new file mode 100644 index 000000000..c00379d75 --- /dev/null +++ b/scripts/verify-dep-lockstep.main.test.mjs @@ -0,0 +1,290 @@ +// End-to-end tests for the dep-lockstep guard's executable path (Copilot, +// #1962). The sibling tests cover the pure helpers; nothing exercised `main()`, +// so a regression in source enumeration, install discovery, lockfile loading, +// the sibling-guard vouch, or the nonzero exit on real skew would have left the +// whole suite green. +// +// Each case builds a throwaway repo — the guard derives its root from its own +// file location, so the script is copied into the fixture rather than pointed +// at one — `git add`s it (the enumeration is `git ls-files`, which reads the +// index), and runs the guard as a subprocess to assert the exit status and +// message. Run via `npm run test:scripts`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + cpSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.join(scriptsDir, ".."); + +/** The real typescript install, symlinked into each fixture so the guard resolves it. */ +const typescriptDir = path.dirname( + createRequire(path.join(repoRoot, "clients", "web", "package.json")).resolve( + "typescript/package.json", + ), +); + +/** A lockfileVersion 3 lockfile, including the `""` root entry npm always writes. */ +const lock = (deps) => ({ + lockfileVersion: 3, + packages: { + "": { name: "fixture" }, + ...Object.fromEntries( + Object.entries(deps).map(([name, version]) => [ + `node_modules/${name}`, + { version }, + ]), + ), + }, +}); + +/** + * Build a fixture repo: shared sources importing `zod` and `express`, a root + * install and one client install, and the guard itself. `rootDeps`/`webDeps` + * decide whether the two installs agree. + */ +function makeFixture({ rootDeps, webDeps, scripts, rawWebLock }) { + // realpath matters: on macOS `tmpdir()` is `/var/...`, a symlink to + // `/private/var/...`. The guard only runs `main()` when `import.meta.url` + // (always the resolved path) matches `process.argv[1]`, so launching it via + // the unresolved path would load the module and silently do nothing — + // exiting 0 with no output, which every assertion here would misread. + const dir = realpathSync(mkdtempSync(path.join(tmpdir(), "dep-lockstep-"))); + const write = (rel, contents) => { + mkdirSync(path.join(dir, path.dirname(rel)), { recursive: true }); + writeFileSync( + path.join(dir, rel), + typeof contents === "string" + ? contents + : JSON.stringify(contents, null, 2), + ); + }; + + write("package.json", { + name: "fixture", + scripts: scripts ?? { + validate: "npm run verify:format-coverage && npm run verify:dep-lockstep", + "verify:format-coverage": "node scripts/verify-format-coverage.mjs", + "verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs", + }, + }); + write("core/client.ts", 'import { z } from "zod";\nexport const s = z;\n'); + write( + "test-servers/src/server.ts", + 'import express from "express";\nexport const app = express;\n', + ); + write( + "vitest.shared.mts", + 'import path from "node:path";\nexport default path;\n', + ); + write("package-lock.json", lock(rootDeps)); + write("clients/web/package.json", { name: "web" }); + write("clients/web/package-lock.json", rawWebLock ?? lock(webDeps)); + + // The guard resolves its repo root from its own location, so it has to live + // inside the fixture; `lib/npm-scripts.mjs` comes along as its import. + mkdirSync(path.join(dir, "scripts", "lib"), { recursive: true }); + for (const rel of [ + "verify-dep-lockstep.mjs", + path.join("lib", "npm-scripts.mjs"), + ]) + cpSync(path.join(scriptsDir, rel), path.join(dir, "scripts", rel)); + + mkdirSync(path.join(dir, "node_modules"), { recursive: true }); + symlinkSync( + typescriptDir, + path.join(dir, "node_modules", "typescript"), + "dir", + ); + + execFileSync("git", ["init", "-q"], { cwd: dir }); + execFileSync("git", ["add", "-A"], { cwd: dir }); + return dir; +} + +function runGuard(dir) { + const r = spawnSync( + process.execPath, + [path.join(dir, "scripts", "verify-dep-lockstep.mjs")], + { cwd: dir, encoding: "utf8" }, + ); + return { status: r.status, out: `${r.stdout}${r.stderr}` }; +} + +/** Run `fn` against a fresh fixture, always cleaning the temp dir up. */ +function withFixture(options, fn) { + const dir = makeFixture(options); + try { + return fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const ALIGNED = { zod: "4.4.3", express: "5.2.1" }; + +test("main: exits 0 when every install agrees", () => { + withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 0, out); + assert.match(out, /verify:dep-lockstep — OK/); + // Both shared trees and the named shared file contributed their imports. + assert.match(out, /2 install-crossing dependencies/); + }); +}); + +test("main: exits 1 and names the skewed package and every holder", () => { + withFixture( + { rootDeps: { ...ALIGNED, zod: "4.3.6" }, webDeps: ALIGNED }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /\bzod\b/); + assert.match(out, /4\.3\.6\s+\(\.\)/); + assert.match(out, /4\.4\.3\s+\(clients\/web\)/); + // `express` agrees, so it must not be reported. + assert.doesNotMatch(out, /^\s+express$/m); + }, + ); +}); + +test("main: a package imported only by test-servers/src is still checked", () => { + // Guards the enumeration of the *second* shared tree: if only `core/` were + // scanned, this skew would pass. + withFixture( + { rootDeps: { ...ALIGNED, express: "5.0.0" }, webDeps: ALIGNED }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /\bexpress\b/); + }, + ); +}); + +test("main: exits 1 when a configured shared source matches no file", () => { + withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { + // Drop `core/` from the index — the other sources keep the file count + // nonzero, which is exactly what the old aggregate check missed. + execFileSync("git", ["rm", "-r", "-q", "--cached", "core"], { cwd: dir }); + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /matched no tracked file/); + assert.match(out, /^\s+core$/m); + }); +}); + +test("main: exits 1 on a lockfile it cannot read, rather than failing open (Copilot, #1962)", () => { + // A v1 lockfile has `dependencies` and no `packages` table. Treating it as an + // install that simply holds nothing would leave the *root's* zod unopposed + // and the skew below reported as aligned — the gate failing open. + withFixture( + { + rootDeps: { ...ALIGNED, zod: "4.3.6" }, + webDeps: ALIGNED, + rawWebLock: { + lockfileVersion: 1, + dependencies: { zod: { version: "4.4.3" } }, + }, + }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /not in a readable format/); + assert.match(out, /clients\/web\/package-lock\.json/); + }, + ); +}); + +test("main: exits 1 on a lockfile with a `packages` table but no root entry", () => { + // The shape check is not just "has a packages key" — a table without the + // `""` root npm always writes is not a lockfile this guard can trust. + withFixture( + { + rootDeps: ALIGNED, + webDeps: ALIGNED, + rawWebLock: { + lockfileVersion: 3, + packages: { "node_modules/zod": { version: "4.4.3" } }, + }, + }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /not in a readable format/); + }, + ); +}); + +test("main: exits 1 on an install whose lockfile is missing (Copilot, #1962)", () => { + // Enrolment is by `package.json`, so a missing lockfile is loud rather than a + // silently absent row. The fixture IS skewed, so dropping the install instead + // would leave the remaining holder unopposed and report aligned. + withFixture( + { rootDeps: { ...ALIGNED, zod: "4.3.6" }, webDeps: ALIGNED }, + (dir) => { + rmSync(path.join(dir, "clients", "web", "package-lock.json")); + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /no lockfile/); + assert.match(out, /clients\/web\/package-lock\.json/); + }, + ); +}); + +test("main: exits 1 when the ROOT lockfile is missing", () => { + // The worst variant: the root is the install every shared source resolves + // from, so omitting it could let the guard pass on client locks alone. + withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { + rmSync(path.join(dir, "package-lock.json")); + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /no lockfile/); + assert.match(out, /^\s+\.\/package-lock\.json$/m); + }); +}); + +test("main: a clients/ dir with no package.json is not an install", () => { + // Enrolling a stray directory would demand a lockfile it should never have. + withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { + mkdirSync(path.join(dir, "clients", "scratch"), { recursive: true }); + writeFileSync( + path.join(dir, "clients", "scratch", "notes.md"), + "scratch\n", + ); + const { status, out } = runGuard(dir); + assert.equal(status, 0, out); + }); +}); + +test("main: exits 1 when the root validate no longer runs the sibling guard", () => { + withFixture( + { + rootDeps: ALIGNED, + webDeps: ALIGNED, + // `verify:format-coverage` dropped from the chain: the vouch must fail + // even though the dependency versions themselves are fine. + scripts: { + validate: "npm run verify:dep-lockstep", + "verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs", + }, + }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /no longer runs `verify:format-coverage`/); + }, + ); +}); diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs new file mode 100644 index 000000000..27b5812c9 --- /dev/null +++ b/scripts/verify-dep-lockstep.mjs @@ -0,0 +1,544 @@ +#!/usr/bin/env node +// Durable guard for the "one version per install-crossing dependency" invariant +// (#1896). v2 is not an npm workspace: the root and each `clients/*` carry their +// own `node_modules`, so the *same* package can resolve to two different +// versions in one process — or, worse, in one `tsc` program. +// +// That second case is what this guard exists for. A client's +// `tsconfig.test.json` compiles first-party sources that live *outside* the +// client (`test-servers/src`, `core/`), and those files resolve their +// dependencies from the **root** install while the client's own sources resolve +// from the client install. When the two copies are the same version the +// duplication is harmless; when they skew, TypeScript must relate two +// structurally-distinct declarations of the same type. +// +// For most packages that is merely redundant work. For a deeply +// recursive-generic type surface it is exponential: zod `4.3.6` (root) against +// zod `4.4.3` (clients/web) made `tsc -b` in `clients/web` exhaust the 4GB +// default heap outright (`FATAL ERROR: Ineffective mark-compacts near heap +// limit`) via `TS2589 Type instantiation is excessively deep`, because every +// `@modelcontextprotocol/*` schema is built out of zod generics. Aligning the +// two copies — changing nothing else — returned the build to its baseline cost. +// +// The candidate set is DERIVED, not hand-listed: it is the packages imported by +// the shared first-party TypeScript — `core/`, `test-servers/src`, and the +// root-owned `vitest.shared.mts` — the surfaces compiled into more than one +// client's program. Skew is then denied by default, with a small +// allowlist of packages verified to tolerate it (below). A dependency that +// starts skewing therefore fails `validate` and forces a decision, rather than +// surfacing months later as an unexplained OOM. +// +// KNOWN BOUNDARY (#1965): the candidate set covers packages the shared sources +// name *directly*. A package whose declarations reach the program only through +// another package's `.d.ts` is invisible here — `@modelcontextprotocol/sdk` is +// the live example, skewed root 1.29.0 vs `clients/web` 1.30.0 and present in +// web's program from both installs, yet never written in first-party code +// (the shared sources import the split `@modelcontextprotocol/client|core|…`). +// Two derivations were measured for closing this. A lockfile dependency +// closure is unusable — 155 packages, 25 of them skewed, nearly all irrelevant +// tooling (`chai`, `qs`, `iconv-lite`) — and it misses the SDK anyway. Reading +// what actually lands in each program (`tsc --listFilesOnly`, keeping packages +// present under two install roots) is both correct and small: 15 for +// `clients/web`, ~10 once nested duplicates are dropped. That is the right +// derivation and is tracked separately, since it changes what the guard +// measures and surfaces skews needing their own decisions. + +import { readFileSync, existsSync, readdirSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { builtinModules, createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { rootReachesScript } from "./lib/npm-scripts.mjs"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +// The first-party source trees that are compiled into more than one install's +// `tsc` program, and so define which dependencies can appear twice in one +// program. `core/` is consumed by every client via the `@inspector/core` alias; +// `test-servers/src` is pulled into the web and cli test projects. +const SHARED_SOURCE_DIRS = ["core", "test-servers/src"]; + +// Individual root-owned TypeScript files that are shared the same way but sit +// outside those trees. `vitest.shared.mts` is imported by every client's vitest +// config, and `verify:typecheck-coverage` already treats it as shared +// non-client source. It imports only Node built-ins today — which is precisely +// why omitting it would go unnoticed until a third-party import appeared there, +// resolved from the root, and skewed (Copilot, #1962). +const SHARED_SOURCE_FILES = ["vitest.shared.mts"]; + +// Packages whose cross-install skew is verified benign, each with the reason. +// This is an allowlist of *names*, not of version pairs, so an ordinary patch +// float within one of these does not churn the file — while any package NOT +// listed here failing the check is a genuine, unreviewed new skew. +// +// Being listed is NOT a blanket exemption: it tolerates skew only *within a +// major version*. Each rationale below establishes that a patch/minor +// difference is harmless, which is not evidence that a React 18-vs-19 or Hono +// 4-vs-5 split across installs would be — that is a different type surface, and +// it fails like anything else (Copilot, #1962). +// +// The admission test is the one the zod incident established: does the +// package's public type surface consist of deeply recursive generics that +// first-party code relates across the boundary? If yes it must stay in +// lockstep; if no, a patch-level difference costs nothing. +const TOLERATED_SKEW = new Map([ + [ + "react", + "Types are shallow interfaces (`ReactNode`, `FC`), not recursive generics; the runtime copies never meet — each client bundles its own.", + ], + [ + "hono", + "Only used behind first-party wrappers in `core/mcp/remote/node`; its generic router types are not related across the boundary.", + ], + [ + "jose", + "Consumed as flat function calls in `core/auth`; no generic type flows between installs.", + ], + [ + "@modelcontextprotocol/ext-apps", + "Plain interface/constant surface for the MCP Apps UI protocol; no generic instantiation to blow up.", + ], +]); + +/** Package names that are Node built-ins (with or without the `node:` prefix). */ +const BUILTINS = new Set([ + ...builtinModules, + ...builtinModules.map((m) => `node:${m}`), +]); + +// A bare package specifier: optional `@scope/`, then a name, then any subpath. +// Anchored so prose that happens to sit after the word `from` in a comment +// ("from cwd omitted") cannot be mistaken for an import. +const PACKAGE_SPECIFIER = /^(?:(@[^/\s]+)\/)?([^@/\s][^/\s]*)(?:\/.*)?$/; + +/** + * The bare package name a module specifier resolves to — `@scope/name` or + * `name`, with any subpath dropped (`zod/v4` → `zod`). Returns null for + * relative paths, built-ins, URLs, and anything not shaped like a specifier. + */ +export function packageNameOf(specifier) { + if (typeof specifier !== "string" || specifier === "") return null; + if (specifier.startsWith(".") || specifier.startsWith("/")) return null; + if (BUILTINS.has(specifier)) return null; + const m = PACKAGE_SPECIFIER.exec(specifier); + if (!m) return null; + const name = m[1] ? `${m[1]}/${m[2]}` : m[2]; + // A protocol-ish specifier (`node:test`, `file:`, `data:`) is not a package. + if (name.includes(":")) return null; + return name; +} + +// Specifiers are extracted with TypeScript's own `preProcessFile` rather than +// by regex (Copilot, #1962 — raised across three review rounds, and correctly). +// A regex scan gets both directions wrong: it *misses* valid syntax (an +// `import x = require(…)` in a `.cts`, an import-attributes argument, a comment +// between tokens — each a silent miss, so the package never enters the +// candidate set and its skew passes), and it *invents* names from prose, since +// `// adapted from "react"` is indistinguishable from an import to a pattern +// that can't tell code from a comment. Every widening of the regex traded one +// of those failures for the other. +// +// `preProcessFile` is TypeScript's lightweight pre-parse scanner — not a full +// parse and no type checking — and it is exactly built for this: it returns +// every module specifier, handling all import forms, trivia, strings, and +// regex literals correctly, and it never sees a comment as code. +// +// typescript is resolved from `clients/web`, which already carries it; the root +// has no TS dependency of its own. The `createRequire` base is load-bearing — +// a bare `import("typescript")` would resolve relative to `scripts/`, not the +// cwd (the same reason `smoke-web-browser.mjs` resolves playwright this way). +let tsCache; +function typescript() { + if (!tsCache) { + const require_ = createRequire( + path.join(repoRoot, "clients", "web", "package.json"), + ); + try { + tsCache = require_("typescript"); + } catch (cause) { + // Fail with the cause, not a bare MODULE_NOT_FOUND: the realistic way to + // get here is a root install run with INSPECTOR_SKIP_CLIENT_INSTALL=1, + // which leaves `clients/web/node_modules` empty. Silently skipping the + // check instead would be worse — an unrun guard guards nothing. + throw new Error( + "verify:dep-lockstep — could not resolve `typescript` from clients/web. " + + "Run `npm install` at the repo root (the postinstall cascade installs each client); " + + "if you set INSPECTOR_SKIP_CLIENT_INSTALL=1, this guard cannot run.", + { cause }, + ); + } + } + return tsCache; +} + +/** + * The package(s) a `/// <reference types="x" />` directive can resolve to + * (Copilot, #1962). Such a directive pulls in declarations exactly like an + * import does, but TypeScript reports it separately from `importedFiles`, so + * reading only the latter would let a referenced package skew unseen. + * + * Both candidates are returned because the directive name is the *type* name, + * not the package: `node` resolves to `@types/node`, while a package shipping + * its own declarations resolves to itself. Returning both over-approximates, + * which is the safe direction — whichever isn't installed drops out. A scoped + * name mangles as `@scope/pkg` → `@types/scope__pkg`, TypeScript's convention. + */ +export function typeReferencePackageNames(directive) { + const name = packageNameOf(directive); + if (!name) return []; + const scoped = /^@([^/]+)\/(.+)$/.exec(name); + const typesName = scoped + ? `@types/${scoped[1]}__${scoped[2]}` + : `@types/${name}`; + return [name, typesName]; +} + +/** + * Every third-party package name whose declarations a blob of TypeScript source + * pulls in — via an import of any form, or a triple-slash type reference. + * Over-approximating is safe (a name absent from every lockfile contributes + * nothing downstream — `@inspector/core` is a build-time alias, not a package, + * and drops out that way); under-approximating is not, since a missed package + * never enters the candidate set and its skew would pass silently. + */ +export function importedPackageNames(source) { + const names = new Set(); + // (source, readImportFiles, detectJavaScriptImports) — the latter two make it + // report `require(…)` and dynamic imports as well as static ones. + const { importedFiles, typeReferenceDirectives } = + typescript().preProcessFile(source, true, true); + for (const { fileName } of importedFiles) { + const name = packageNameOf(fileName); + if (name) names.add(name); + } + for (const { fileName } of typeReferenceDirectives ?? []) + for (const name of typeReferencePackageNames(fileName)) names.add(name); + return names; +} + +/** + * Top-level installed versions of every package in a parsed lockfile, keyed by + * package name. Only `node_modules/<pkg>` entries count — a *nested* + * `node_modules/a/node_modules/b` is npm resolving a transitive conflict inside + * one install, which is routine and not what this guard is about. + */ +export function topLevelLockVersions(lock) { + const versions = new Map(); + for (const [entryPath, entry] of Object.entries(lock?.packages ?? {})) { + const m = /^node_modules\/(@[^/]+\/[^/]+|[^@/][^/]*)$/.exec(entryPath); + if (!m || typeof entry?.version !== "string") continue; + versions.set(m[1], entry.version); + } + return versions; +} + +/** + * Whether a parsed lockfile has the shape this guard can read: a + * `lockfileVersion` 2+ `packages` table, keyed by install path with `""` for + * the root project. + * + * This is checked rather than tolerated because the gate is deny-by-default and + * `topLevelLockVersions` returns an empty map for anything else. An unreadable + * lockfile would otherwise contribute no holders, and a real skew among the + * remaining installs would be reported as aligned — the gate failing *open*, + * which is the one way it must never fail (Copilot, #1962). A v1 lockfile + * (`dependencies` only, no `packages`) lands here too, correctly: this guard + * cannot read it, so it must say so rather than skip the install. + */ +export function hasReadableLockShape(lock) { + // The declared version is checked, not just inferred from the presence of a + // `packages` key: this function and the diagnostic it drives both promise + // "lockfileVersion 2+", so a file claiming v1 while carrying a `packages` + // table must be rejected rather than half-trusted (Copilot, #1962). + const version = lock?.lockfileVersion; + if (typeof version !== "number" || !Number.isFinite(version) || version < 2) + return false; + const packages = lock.packages; + if (typeof packages !== "object" || packages === null) return false; + return Object.prototype.hasOwnProperty.call(packages, ""); +} + +/** + * Find candidate packages that resolve to more than one version across the + * installs. `installs` is an array of `{ dir, versions }`. Returns one entry per + * skewed package, sorted by name, each listing the version each install holds. + * Packages present in fewer than two installs cannot skew and are skipped. + */ +export function findSkew(candidates, installs) { + const skewed = []; + for (const name of [...candidates].sort()) { + const holders = installs + .filter(({ versions }) => versions.has(name)) + .map(({ dir, versions }) => ({ dir, version: versions.get(name) })); + if (holders.length < 2) continue; + const distinct = new Set(holders.map((h) => h.version)); + if (distinct.size > 1) skewed.push({ name, holders }); + } + return skewed; +} + +/** + * The major-version component of a lockfile version string. Prerelease and + * build metadata are irrelevant here (`2.0.0-beta.5` → `2`). Returns null for + * anything not starting with an integer, which is treated as "cannot prove same + * major" and therefore fails rather than passes. + */ +export function majorOf(version) { + const m = /^(\d+)\./.exec(String(version ?? "")); + return m ? m[1] : null; +} + +/** + * Split skewed packages into the tolerated ones and the failures. + * + * Being on the allowlist tolerates skew only *within a major version*: each + * entry's rationale establishes that a patch/minor difference is benign, which + * says nothing about a major split, where the type surface itself changes. So a + * listed package whose holders disagree on major is still a failure. + */ +export function partitionSkew(skewed, tolerated = TOLERATED_SKEW) { + const isTolerated = (s) => { + if (!tolerated.has(s.name)) return false; + const majors = new Set(s.holders.map((h) => majorOf(h.version))); + return majors.size === 1 && !majors.has(null); + }; + return { + failures: skewed.filter((s) => !isTolerated(s)), + ignored: skewed.filter(isTolerated), + }; +} + +// The TypeScript extensions the shared trees can hold. Deliberately the same +// four `verify:format-coverage` and `verify:typecheck-coverage` gate on: a +// `.mts`/`.cts` under `core/` or `test-servers/src` is typechecked like any +// other source, so its imports must reach the candidate set too. None exist +// under those trees today, which is exactly why omitting them would go +// unnoticed until a new shared dependency arrived through one and skewed. +const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"]; + +/** + * Whether a repo-relative path is shared first-party TypeScript — under one of + * the shared trees, or one of the individually-named shared files. + */ +export function isSharedSourceFile(file) { + if (SHARED_SOURCE_FILES.includes(file)) return true; + if (!SOURCE_EXTENSIONS.some((ext) => file.endsWith(ext))) return false; + // Anchored on a path boundary so a sibling whose name merely starts with a + // shared dir (`core-internal/`, `test-servers/src-legacy/`) isn't swept in. + return SHARED_SOURCE_DIRS.some((dir) => file.startsWith(`${dir}/`)); +} + +/** + * Which configured shared sources contributed no file to `files`. Each dir and + * each individually-named file must be represented; an aggregate count can't + * see one of them going missing, because the others keep the total nonzero. + */ +export function sourcesWithNoFiles( + files, + dirs = SHARED_SOURCE_DIRS, + named = SHARED_SOURCE_FILES, +) { + const missingDirs = dirs.filter( + (dir) => !files.some((f) => f.startsWith(`${dir}/`)), + ); + const missingNamed = named.filter((name) => !files.includes(name)); + return [...missingDirs, ...missingNamed]; +} + +/** Tracked TypeScript files under the shared first-party source trees. */ +function sharedSourceFiles() { + const out = execFileSync( + "git", + [ + "ls-files", + "--", + ...SHARED_SOURCE_DIRS.map((d) => `${d}/**`), + ...SHARED_SOURCE_FILES, + ], + { cwd: repoRoot, encoding: "utf8" }, + ); + return out.split("\n").filter(isSharedSourceFile); +} + +/** + * The installs to compare: the repo root plus every `clients/*` that carries a + * lockfile. Discovered from disk rather than listed, so a new client is covered + * without editing this guard (the same enrollment style as + * `verify:typecheck-coverage`). + */ +function installDirs() { + const clientsDir = path.join(repoRoot, "clients"); + const clients = existsSync(clientsDir) + ? readdirSync(clientsDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => `clients/${e.name}`) + .sort() + : []; + // Enrolment is by `package.json` — an install we are meant to compare — + // NOT by the presence of a lockfile (Copilot, #1962). Filtering on the + // lockfile made a missing one silently drop that install from the + // comparison; for the root, the install every shared source resolves from, + // that meant the guard could report success from client locks alone. A + // missing lockfile is now a loud failure in `main`, not an absent row. The + // root is always enrolled: it is this repo, so its manifest is a given. + return ["."].concat( + clients.filter((dir) => + existsSync(path.join(repoRoot, dir, "package.json")), + ), + ); +} + +/** + * Run the guard. Prints its verdict and `process.exit(1)`s on any failure. + * Called only when this file is executed directly — importing it (for tests) + * gives access to the pure helpers above without running any of this. + */ +export function main() { + const rootScripts = JSON.parse( + readFileSync(path.join(repoRoot, "package.json"), "utf8"), + ).scripts; + + // Vouch for a sibling guard: a guard cannot detect being unrun itself, but the + // three can vouch for one another, so dropping any single one from `validate` + // is caught by another. `verify:format-coverage` vouches for this one in turn. + if (!rootReachesScript(rootScripts, "verify:format-coverage")) { + console.error( + "verify:dep-lockstep — the root `validate` no longer runs `verify:format-coverage` (a sibling guard). Restore it.", + ); + process.exit(1); + } + + const files = sharedSourceFiles(); + // Per-source, not an aggregate count (Copilot, #1962): `vitest.shared.mts` + // alone keeps the total nonzero, so a moved or renamed `core/` would leave + // the guard checking a near-empty candidate set and passing. Every configured + // source must contribute, or the enumeration is broken. + const empty = sourcesWithNoFiles(files); + if (empty.length > 0) { + console.error( + `verify:dep-lockstep — ${empty.length} configured shared source(s) matched no tracked file:\n`, + ); + for (const source of empty) console.error(` ${source}`); + console.error( + "\nThe guard would derive its candidates from an incomplete set and pass on skew it should catch." + + "\nA path was moved or renamed — fix SHARED_SOURCE_DIRS / SHARED_SOURCE_FILES in this file.", + ); + process.exit(1); + } + + const candidates = new Set(); + for (const file of files) { + const source = readFileSync(path.join(repoRoot, file), "utf8"); + for (const name of importedPackageNames(source)) candidates.add(name); + } + + const dirs = installDirs(); + + // A missing lockfile is a failure, not a skipped install: dropping one would + // remove its versions from the comparison and could report a real skew as + // aligned. + const missing = dirs.filter( + (dir) => !existsSync(path.join(repoRoot, dir, "package-lock.json")), + ); + if (missing.length > 0) { + console.error( + `verify:dep-lockstep — ${missing.length} install(s) have a package.json but no lockfile:\n`, + ); + for (const dir of missing) console.error(` ${dir}/package-lock.json`); + console.error( + "\nEvery install must be compared; skipping one could report a real skew as aligned." + + "\nRun `npm install` there, or remove the install if it is no longer part of the repo.", + ); + process.exit(1); + } + + const locks = dirs.map((dir) => { + const file = path.join(repoRoot, dir, "package-lock.json"); + let lock; + try { + lock = JSON.parse(readFileSync(file, "utf8")); + } catch (cause) { + // Unparseable is the same failure as unreadable — say which file, rather + // than dying on a raw SyntaxError with no path in it. + throw new Error( + `verify:dep-lockstep — could not parse ${dir}/package-lock.json.`, + { cause }, + ); + } + return { dir, lock }; + }); + + // Refuse to compare against a lockfile whose shape we can't read, instead of + // treating it as an install that holds nothing — see `hasReadableLockShape`. + const unreadable = locks.filter(({ lock }) => !hasReadableLockShape(lock)); + if (unreadable.length > 0) { + console.error( + `verify:dep-lockstep — ${unreadable.length} lockfile(s) are not in a readable format:\n`, + ); + for (const { dir } of unreadable) + console.error(` ${dir}/package-lock.json`); + console.error( + "\nThis guard reads the `packages` table of a lockfileVersion 2+ lockfile. Without it the install" + + "\ncontributes no versions, so a real skew among the others would be reported as aligned — the gate" + + "\nfailing open. Regenerate the lockfile with a current npm (`npm install`).", + ); + process.exit(1); + } + + const installs = locks.map(({ dir, lock }) => ({ + dir, + versions: topLevelLockVersions(lock), + })); + + const { failures, ignored } = partitionSkew(findSkew(candidates, installs)); + + if (failures.length > 0) { + console.error( + `verify:dep-lockstep — ${failures.length} ${failures.length === 1 ? "dependency resolves" : "dependencies resolve"} to different versions across installs:\n`, + ); + let anyListed = false; + for (const { name, holders } of failures) { + // A package already on the allowlist reached here only by skewing across + // a MAJOR boundary, so say that rather than advising an entry that exists. + const listed = TOLERATED_SKEW.has(name); + anyListed ||= listed; + console.error( + ` ${name}${listed ? " (allowlisted — but this is a MAJOR skew)" : ""}`, + ); + for (const { dir, version } of holders) + console.error(` ${version} (${dir})`); + } + const shared = [...SHARED_SOURCE_DIRS, ...SHARED_SOURCE_FILES].join(", "); + console.error( + "\nThese packages' types are compiled into a single `tsc` program from two installs" + + `\n(${shared} resolve from the root, a client's own sources from the client),` + + "\nso a version skew makes TypeScript relate two structurally-distinct copies of the same" + + "\ntype. For a recursive-generic surface like zod that is what exhausted the tsc heap in #1896.", + ); + console.error( + "\nAlign them — `npm install <pkg>@<version>` in each install that declares the package, so all" + + "\nlockfiles agree. (Don't add it to an install that doesn't declare it: a package absent from an" + + "\ninstall can't skew.) If instead its types genuinely cannot blow up, add it to TOLERATED_SKEW in" + + "\nscripts/verify-dep-lockstep.mjs with the reason. See AGENTS.md.", + ); + if (anyListed) + console.error( + "\nNote: an allowlisted package is tolerated only WITHIN a major version — the rationale for one" + + "\nestablishes that a patch/minor difference is benign, not that a major split is. Align the major.", + ); + process.exit(1); + } + + const note = ignored.length > 0 ? `, ${ignored.length} tolerated` : ""; + console.log( + `verify:dep-lockstep — OK: ${candidates.size} install-crossing dependencies agree across ${installs.length} installs${note}.`, + ); +} + +// Run only when executed directly (`node scripts/verify-dep-lockstep.mjs`); +// importing this file (tests) exposes the pure helpers without running the guard. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) + main(); diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs new file mode 100644 index 000000000..55ca3b6f6 --- /dev/null +++ b/scripts/verify-dep-lockstep.test.mjs @@ -0,0 +1,491 @@ +// Table-driven tests for the pure helpers of the dep-lockstep guard (#1896). +// One case per rule the guard encodes — the comment names the rule, so a future +// change that relaxes one is visible as a deleted assertion rather than a quiet +// behavior shift. Run via `npm run test:scripts` (node:test; the root has no +// vitest harness). + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + findSkew, + hasReadableLockShape, + importedPackageNames, + isSharedSourceFile, + majorOf, + packageNameOf, + partitionSkew, + sourcesWithNoFiles, + topLevelLockVersions, + typeReferencePackageNames, +} from "./verify-dep-lockstep.mjs"; + +test("isSharedSourceFile: all four TS extensions, not just .ts/.tsx", () => { + // `.mts`/`.cts` are gated by `verify:format-coverage` and + // `verify:typecheck-coverage` too. None exist under the shared trees today, + // so dropping them here would go unnoticed until a new shared dependency + // arrived through one — the skew this guard exists to catch (Copilot, #1962). + for (const ext of [".ts", ".tsx", ".mts", ".cts"]) { + assert.equal(isSharedSourceFile(`core/mcp/thing${ext}`), true, ext); + assert.equal( + isSharedSourceFile(`test-servers/src/thing${ext}`), + true, + `test-servers ${ext}`, + ); + } +}); + +test("isSharedSourceFile: non-TS files and other trees are excluded", () => { + const rejected = [ + "core/README.md", // not TypeScript + "core/mcp/data.json", + "clients/web/src/App.tsx", // a client's own sources resolve from the client + "scripts/verify-dep-lockstep.mjs", + "test-servers/configs/modern-http.json", + // Path-boundary anchoring: a sibling dir whose name merely starts with a + // shared dir's name must not be swept in. + "core-internal/thing.ts", + "test-servers/src-legacy/thing.ts", + ]; + for (const file of rejected) + assert.equal(isSharedSourceFile(file), false, file); +}); + +test("sourcesWithNoFiles: each configured source must contribute (Copilot, #1962)", () => { + const dirs = ["core", "test-servers/src"]; + const named = ["vitest.shared.mts"]; + const complete = [ + "core/mcp/a.ts", + "test-servers/src/b.ts", + "vitest.shared.mts", + ]; + assert.deepEqual(sourcesWithNoFiles(complete, dirs, named), []); + + // The failure an aggregate count can't see: `core/` moved, but the other two + // sources keep `files.length` nonzero, so the guard would derive candidates + // from an incomplete set and pass on skew it should catch. + assert.deepEqual( + sourcesWithNoFiles( + ["test-servers/src/b.ts", "vitest.shared.mts"], + dirs, + named, + ), + ["core"], + ); + assert.deepEqual( + sourcesWithNoFiles(["core/mcp/a.ts", "test-servers/src/b.ts"], dirs, named), + ["vitest.shared.mts"], + ); + assert.deepEqual(sourcesWithNoFiles([], dirs, named), [ + "core", + "test-servers/src", + "vitest.shared.mts", + ]); +}); + +test("sourcesWithNoFiles: a prefix sibling does not vouch for a dir", () => { + // `core-internal/` starts with `core` but is not it — the boundary check has + // to be on a path separator, or a renamed dir would look present. + assert.deepEqual(sourcesWithNoFiles(["core-internal/a.ts"], ["core"], []), [ + "core", + ]); +}); + +test("packageNameOf: bare names, scopes, and subpaths", () => { + const cases = [ + ["zod", "zod"], + ["zod/v4", "zod"], // subpath dropped — one package, one version + ["@modelcontextprotocol/client", "@modelcontextprotocol/client"], + ["@modelcontextprotocol/client/core", "@modelcontextprotocol/client"], + ["react-dom/client", "react-dom"], + ]; + for (const [input, expected] of cases) + assert.equal(packageNameOf(input), expected, input); +}); + +test("packageNameOf: non-packages are rejected", () => { + // Relative/absolute paths, built-ins with and without the `node:` prefix, + // protocol specifiers, and prose that follows the word `from` in a comment. + const rejected = [ + "./foo", + "../core/mcp", + "/abs/path", + "fs", + "path", + "node:crypto", + "node:test", + "file:", + "data:text/plain,x", + "cwd omitted", + "", + ]; + for (const input of rejected) + assert.equal(packageNameOf(input), null, JSON.stringify(input)); + assert.equal(packageNameOf(undefined), null); +}); + +test("importedPackageNames: CommonJS and awkward dynamic-import forms (Copilot, #1962)", () => { + // Under-approximating is the dangerous direction: a package the scan misses + // never enters the candidate set, so its skew passes the guard silently. + // `.cts` sources in the shared trees use `import x = require(…)` as ordinary + // syntax, and a dynamic import may carry import attributes or a static + // template literal — none of which the original three patterns matched. + const source = ` + import express = require("express"); + const yaml = require("yaml"); + const a = await import("undici", { with: { type: "json" } }); + const b = await import(\`jose\`); + `; + assert.deepEqual([...importedPackageNames(source)].sort(), [ + "express", + "jose", + "undici", + "yaml", + ]); +}); + +test("importedPackageNames: comment trivia between tokens (Copilot, #1962)", () => { + // TypeScript allows a comment anywhere whitespace is legal, so all of these + // are valid imports. Missing one is the dangerous direction: the package + // never enters the candidate set and its skew passes the guard silently. + const source = ` + import { a } from /* explanation */ "express"; + const b = await import(/* webpackIgnore: true */ "undici"); + const c = require(/* lazy */ "yaml"); + import /* side effect */ "pino"; + `; + assert.deepEqual([...importedPackageNames(source)].sort(), [ + "express", + "pino", + "undici", + "yaml", + ]); +}); + +test("importedPackageNames: line-comment trivia, not just block (Copilot, #1962)", () => { + // `//` runs to end-of-line and is legal in every position a block comment is, + // so a specifier can sit on the next line and these are still valid imports. + const source = [ + "import { a } from // reason", + ' "express";', + "const b = await import(// lazy", + ' "undici");', + "const c = require(// lazy", + ' "yaml");', + ].join("\n"); + assert.deepEqual([...importedPackageNames(source)].sort(), [ + "express", + "undici", + "yaml", + ]); +}); + +test("importedPackageNames: static, side-effect, and dynamic forms; builtins and relatives dropped", () => { + const source = ` + import { z } from "zod/v4"; + export type { Foo } from '@modelcontextprotocol/core'; + import "./side-effect.css"; + import "pino"; + const mod = await import("chokidar"); + import fs from "node:fs"; + import { helper } from "../local/helper"; + `; + assert.deepEqual([...importedPackageNames(source)].sort(), [ + "@modelcontextprotocol/core", + "chokidar", + "pino", + "zod", + ]); +}); + +test("importedPackageNames: triple-slash type references count (Copilot, #1962)", () => { + // A `/// <reference types="x" />` pulls in declarations exactly like an + // import, but TypeScript reports it in `typeReferenceDirectives`, not + // `importedFiles` — so reading only the latter let a referenced package skew + // unseen. `path` references name a file, not a package, and are ignored. + const source = [ + '/// <reference types="node" />', + '/// <reference types="express" />', + '/// <reference path="./local.d.ts" />', + 'import { z } from "zod";', + ].join("\n"); + assert.deepEqual([...importedPackageNames(source)].sort(), [ + "@types/express", + "@types/node", + "express", + "node", + "zod", + ]); +}); + +test("typeReferencePackageNames: both the bare and the @types form (Copilot, #1962)", () => { + // The directive names a *type*, not a package: `node` resolves to + // `@types/node`, while a package shipping its own declarations resolves to + // itself. Returning both over-approximates, the safe direction — whichever + // isn't installed drops out downstream. + assert.deepEqual(typeReferencePackageNames("node"), ["node", "@types/node"]); + // Scoped names mangle with a double underscore, TypeScript's convention. + assert.deepEqual(typeReferencePackageNames("@scope/pkg"), [ + "@scope/pkg", + "@types/scope__pkg", + ]); + assert.deepEqual(typeReferencePackageNames("./relative"), []); + assert.deepEqual(typeReferencePackageNames(""), []); +}); + +test("importedPackageNames: prose in comments never becomes a package (Copilot, #1962)", () => { + // The regex scan this replaced could not tell code from a comment, so + // `// adapted from "react"` added `react` to the candidate set — and if that + // installed package were skewed, an unrelated comment would fail `validate`. + // These use REAL package names, which is the case the old prose test missed: + // it only passed because `cwd omitted` isn't a valid package name. + const source = ` + // adapted from "react" + /** Mirrors the behavior of "express", see require("yaml") below. */ + /** The excluded set derived from \\\`hono\\\`-style paths. */ + // const disabled = await import("undici"); + import { z } from "zod"; + `; + assert.deepEqual([...importedPackageNames(source)], ["zod"]); +}); + +test("importedPackageNames: a specifier inside a string literal is not an import", () => { + const source = ` + const msg = 'run require("chokidar") to load it'; + const re = /"jose"/; + import { z } from "zod"; + `; + assert.deepEqual([...importedPackageNames(source)], ["zod"]); +}); + +test("topLevelLockVersions: nested duplicates are ignored", () => { + // A nested `node_modules/a/node_modules/b` is npm resolving a transitive + // conflict *inside* one install — routine, and not the cross-install skew + // this guard is about (`cosmiconfig`'s yaml@1 alongside the top-level yaml@2 + // is the live example). + const lock = { + packages: { + "": { name: "root" }, + "node_modules/zod": { version: "4.4.3" }, + "node_modules/yaml": { version: "2.9.0" }, + "node_modules/cosmiconfig/node_modules/yaml": { version: "1.10.3" }, + "node_modules/@modelcontextprotocol/client": { version: "2.0.0-beta.5" }, + "node_modules/no-version": { resolved: "https://example.test/x.tgz" }, + }, + }; + assert.deepEqual([...topLevelLockVersions(lock)].sort(), [ + ["@modelcontextprotocol/client", "2.0.0-beta.5"], + ["yaml", "2.9.0"], + ["zod", "4.4.3"], + ]); +}); + +test("topLevelLockVersions: a malformed or empty lockfile yields nothing", () => { + // Safe as a pure helper *because* `hasReadableLockShape` rejects these before + // any comparison — an empty map reaching `findSkew` is the fail-open path. + for (const lock of [undefined, null, {}, { packages: {} }]) + assert.equal(topLevelLockVersions(lock).size, 0); +}); + +test("hasReadableLockShape: only a v2+ packages table with a root entry (Copilot, #1962)", () => { + assert.equal( + hasReadableLockShape({ lockfileVersion: 3, packages: { "": {} } }), + true, + ); + assert.equal( + hasReadableLockShape({ + lockfileVersion: 2, + packages: { "": {}, "node_modules/zod": { version: "4.4.3" } }, + }), + true, + ); + const rejected = [ + undefined, + null, + {}, + { lockfileVersion: 3, packages: null }, + { lockfileVersion: 3, packages: [] }, // an array has no `""` key + { lockfileVersion: 3, packages: {} }, // no root entry + { + lockfileVersion: 3, + packages: { "node_modules/zod": { version: "4.4.3" } }, + }, + { lockfileVersion: 1, dependencies: { zod: { version: "4.4.3" } } }, + // A declared v1 carrying a `packages` table: the version is checked, not + // inferred from the key's presence, so this is rejected rather than + // half-trusted into an empty (fail-open) version map. + { lockfileVersion: 1, packages: { "": {} } }, + { packages: { "": {} } }, // no declared version at all + { lockfileVersion: "3", packages: { "": {} } }, // not a number + ]; + for (const lock of rejected) + assert.equal(hasReadableLockShape(lock), false, JSON.stringify(lock)); +}); + +test("findSkew: reports a package held at two versions", () => { + const installs = [ + { dir: ".", versions: new Map([["zod", "4.3.6"]]) }, + { dir: "clients/web", versions: new Map([["zod", "4.4.3"]]) }, + { dir: "clients/cli", versions: new Map([["zod", "4.4.3"]]) }, + ]; + assert.deepEqual(findSkew(new Set(["zod"]), installs), [ + { + name: "zod", + holders: [ + { dir: ".", version: "4.3.6" }, + { dir: "clients/web", version: "4.4.3" }, + { dir: "clients/cli", version: "4.4.3" }, + ], + }, + ]); +}); + +test("findSkew: agreement and single-install packages are not skew", () => { + const installs = [ + { + dir: ".", + versions: new Map([ + ["zod", "4.4.3"], + ["express", "5.2.1"], + ]), + }, + { dir: "clients/web", versions: new Map([["zod", "4.4.3"]]) }, + ]; + // `express` lives in one install only, so it cannot skew — a package absent + // from a client is not a finding. + assert.deepEqual(findSkew(new Set(["zod", "express"]), installs), []); +}); + +test("findSkew: a candidate in no lockfile is inert", () => { + // `@inspector/core` is a build-time alias, not a package; the scan picks it + // up and it must drop out here rather than error. + const installs = [ + { dir: ".", versions: new Map([["zod", "4.4.3"]]) }, + { dir: "clients/web", versions: new Map([["zod", "4.4.3"]]) }, + ]; + assert.deepEqual(findSkew(new Set(["@inspector/core"]), installs), []); +}); + +test("findSkew: results are sorted by package name", () => { + const installs = [ + { + dir: ".", + versions: new Map([ + ["zod", "1.0.0"], + ["hono", "1.0.0"], + ]), + }, + { + dir: "clients/web", + versions: new Map([ + ["zod", "2.0.0"], + ["hono", "2.0.0"], + ]), + }, + ]; + assert.deepEqual( + findSkew(new Set(["zod", "hono"]), installs).map((s) => s.name), + ["hono", "zod"], + ); +}); + +test("partitionSkew: the allowlist is by name, not by version pair", () => { + // So an ordinary patch float within a tolerated package does not churn the + // allowlist, while any *unlisted* package that starts skewing still fails. + const skewed = [ + { + name: "react", + holders: [ + { dir: ".", version: "19.2.7" }, + { dir: "clients/web", version: "19.2.8" }, + ], + }, + { + name: "zod", + holders: [ + { dir: ".", version: "4.3.6" }, + { dir: "clients/web", version: "4.4.3" }, + ], + }, + ]; + const tolerated = new Map([["react", "shallow interfaces"]]); + const { failures, ignored } = partitionSkew(skewed, tolerated); + assert.deepEqual( + failures.map((s) => s.name), + ["zod"], + ); + assert.deepEqual( + ignored.map((s) => s.name), + ["react"], + ); +}); + +test("partitionSkew: deny by default — nothing tolerated fails everything", () => { + const skewed = [{ name: "zod", holders: [{ dir: ".", version: "1.0.0" }] }]; + assert.equal(partitionSkew(skewed, new Map()).failures.length, 1); +}); + +test("partitionSkew: the allowlist tolerates skew only within a major (Copilot, #1962)", () => { + // Each rationale establishes that a patch/minor difference is benign; that is + // not evidence a React 18-vs-19 split is, so a listed package still fails + // across a major boundary. + const tolerated = new Map([["react", "shallow interfaces"]]); + const withinMajor = [ + { + name: "react", + holders: [ + { dir: ".", version: "19.2.7" }, + { dir: "clients/web", version: "19.2.8" }, + ], + }, + ]; + const acrossMajor = [ + { + name: "react", + holders: [ + { dir: ".", version: "18.3.1" }, + { dir: "clients/web", version: "19.2.8" }, + ], + }, + ]; + assert.equal(partitionSkew(withinMajor, tolerated).failures.length, 0); + assert.equal(partitionSkew(withinMajor, tolerated).ignored.length, 1); + assert.equal(partitionSkew(acrossMajor, tolerated).failures.length, 1); + assert.equal(partitionSkew(acrossMajor, tolerated).ignored.length, 0); +}); + +test("partitionSkew: an unparseable version can't be proven same-major, so it fails", () => { + const tolerated = new Map([["react", "shallow interfaces"]]); + const skewed = [ + { + name: "react", + holders: [ + { dir: ".", version: "19.2.7" }, + { dir: "clients/web", version: "next" }, + ], + }, + ]; + assert.equal(partitionSkew(skewed, tolerated).failures.length, 1); +}); + +test("majorOf: prerelease and build metadata are irrelevant", () => { + const cases = [ + ["4.4.3", "4"], + ["2.0.0-beta.5", "2"], + ["19.2.8", "19"], + ["1.10.3+build.7", "1"], + ]; + for (const [input, expected] of cases) + assert.equal(majorOf(input), expected, input); + for (const bad of ["next", "", undefined, null, "v4.4.3"]) + assert.equal(majorOf(bad), null, JSON.stringify(bad)); +}); + +test("isSharedSourceFile: individually-named shared files are included (Copilot, #1962)", () => { + // `vitest.shared.mts` is root-owned, imported by every client's vitest + // config, and already treated as shared by `verify:typecheck-coverage`. It + // imports only Node built-ins today, which is why omitting it would go + // unnoticed until a third-party import appeared there and skewed. + assert.equal(isSharedSourceFile("vitest.shared.mts"), true); + // Still anchored: a same-named file nested elsewhere is not the shared one. + assert.equal(isSharedSourceFile("clients/web/vitest.shared.mts"), false); +}); diff --git a/scripts/verify-format-coverage.main.test.mjs b/scripts/verify-format-coverage.main.test.mjs new file mode 100644 index 000000000..e10130f65 --- /dev/null +++ b/scripts/verify-format-coverage.main.test.mjs @@ -0,0 +1,97 @@ +// Regression tests for `verify-format-coverage`'s sibling-guard vouch (Copilot, +// #1962). The three root guards form a cycle so that dropping any one from +// `validate` is caught by another — but the vouch branch itself had no test, so +// a typo in a sibling's name would leave `test:scripts` green while that guard +// silently stopped being enforced. That is the same "a gate that stops gating" +// failure the cycle exists to prevent, one level up. +// +// The vouch runs before any file enumeration, so the fixture needs only a +// `package.json` and the script. Run via `npm run test:scripts`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + cpSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); + +/** A `validate` chain running each named guard, plus the always-present self. */ +function scriptsRunning(guards) { + const scripts = { + validate: ["verify:format-coverage", ...guards] + .map((g) => `npm run ${g}`) + .join(" && "), + "verify:format-coverage": "node scripts/verify-format-coverage.mjs", + }; + for (const g of guards) scripts[g] = `node scripts/${g.slice(7)}.mjs`; + return scripts; +} + +/** + * Run `verify-format-coverage` in a throwaway repo whose root `validate` runs + * exactly `guards`. realpath'd because the script only executes when + * `import.meta.url` matches `process.argv[1]`, and macOS `tmpdir()` is a + * symlink — see the note in `verify-dep-lockstep.main.test.mjs`. + */ +function runWithGuards(guards) { + const dir = realpathSync(mkdtempSync(path.join(tmpdir(), "format-cov-"))); + try { + writeFileSync( + path.join(dir, "package.json"), + JSON.stringify( + { name: "fixture", scripts: scriptsRunning(guards) }, + null, + 2, + ), + ); + mkdirSync(path.join(dir, "scripts", "lib"), { recursive: true }); + for (const rel of [ + "verify-format-coverage.mjs", + path.join("lib", "npm-scripts.mjs"), + ]) + cpSync(path.join(scriptsDir, rel), path.join(dir, "scripts", rel)); + execFileSync("git", ["init", "-q"], { cwd: dir }); + execFileSync("git", ["add", "-A"], { cwd: dir }); + + const r = spawnSync( + process.execPath, + [path.join(dir, "scripts", "verify-format-coverage.mjs")], + { cwd: dir, encoding: "utf8" }, + ); + return { status: r.status, out: `${r.stdout}${r.stderr}` }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const BOTH_SIBLINGS = ["verify:typecheck-coverage", "verify:dep-lockstep"]; + +test("vouch: fails when `verify:dep-lockstep` is dropped from validate", () => { + const { status, out } = runWithGuards(["verify:typecheck-coverage"]); + assert.equal(status, 1, out); + assert.match(out, /no longer runs `verify:dep-lockstep`/); +}); + +test("vouch: fails when `verify:typecheck-coverage` is dropped from validate", () => { + const { status, out } = runWithGuards(["verify:dep-lockstep"]); + assert.equal(status, 1, out); + assert.match(out, /no longer runs `verify:typecheck-coverage`/); +}); + +test("vouch: passes when both siblings are still wired", () => { + // The run still fails afterwards — the fixture has no client manifests to + // harvest globs from — so assert on the *reason*, not the exit status: no + // sibling may be reported missing. + const { out } = runWithGuards(BOTH_SIBLINGS); + assert.doesNotMatch(out, /no longer runs/); +}); diff --git a/scripts/verify-format-coverage.mjs b/scripts/verify-format-coverage.mjs index 8963c8c24..20ea65db4 100644 --- a/scripts/verify-format-coverage.mjs +++ b/scripts/verify-format-coverage.mjs @@ -166,15 +166,18 @@ function trackedSourceFiles() { return out.split("\n").filter(Boolean); } -// Vouch for the sibling guard: a guard can't detect being unrun itself, but the -// two coverage guards can each assert the other is still wired into `validate`, -// so dropping either is caught here. Only deleting both slips through. +// Vouch for the sibling guards: a guard can't detect being unrun itself, so the +// three form a cycle instead. This one checks BOTH others; each of them checks +// only this one. So dropping `verify:typecheck-coverage` or +// `verify:dep-lockstep` is caught here, and dropping *this* guard is caught by +// either of them. Only removing all three at once slips through. const rootScripts = JSON.parse( readFileSync(path.join(repoRoot, "package.json"), "utf8"), ).scripts; -if (!rootReachesScript(rootScripts, "verify:typecheck-coverage")) { +for (const sibling of ["verify:typecheck-coverage", "verify:dep-lockstep"]) { + if (rootReachesScript(rootScripts, sibling)) continue; console.error( - "verify:format-coverage — the root `validate` no longer runs `verify:typecheck-coverage` (its sibling guard). Restore it.", + `verify:format-coverage — the root \`validate\` no longer runs \`${sibling}\` (its sibling guard). Restore it.`, ); process.exit(1); } diff --git a/specification/v2_auth_ema.md b/specification/v2_auth_ema.md index 6f314d1f7..a7a4c9176 100644 --- a/specification/v2_auth_ema.md +++ b/specification/v2_auth_ema.md @@ -46,7 +46,7 @@ _Audited June 2026 against the [EMA extension spec](https://modelcontextprotocol ### TypeScript SDK (implemented) -Inspector depends on **`@modelcontextprotocol/sdk` v1.x** only (`^1.29.0` in root `package.json`). Standard OAuth and EMA both build on that package — there is **no** `@modelcontextprotocol/client` v2 dependency in the tree today. +Inspector depends on the **v2 SDK packages** — `@modelcontextprotocol/client` / `core` / `server` / `server-legacy` at **2.0.0**, declared in the root `package.json` only. Standard OAuth and EMA both build on `@modelcontextprotocol/client`; every module in the table below imports it. The v1 `@modelcontextprotocol/sdk` is **not** a dependency of this repo and must not become one — it appears in the lock files solely as a `peer` pulled in by `ext-apps`. | Concern | Package / module | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/specification/v2_auth_hardening.md b/specification/v2_auth_hardening.md index 1595bc9ea..78eb26b10 100644 --- a/specification/v2_auth_hardening.md +++ b/specification/v2_auth_hardening.md @@ -6,7 +6,7 @@ As-built status for aligning Inspector with the **authorization hardening** SEPs in the MCP **`2026-07-28`** release — tracked by [#1527](https://github.com/modelcontextprotocol/inspector/issues/1527). -Inspector is on `@modelcontextprotocol/client` **2.0.0-beta.4**. Connect-time standard OAuth is delegated to SDK `auth()`; Inspector owns storage, callbacks, remoting, EMA host flow, and mid-session UX. See [SDK consolidation](v2_auth_sdk_consolidation.md). +Inspector is on `@modelcontextprotocol/client` **2.0.0**. Connect-time standard OAuth is delegated to SDK `auth()`; Inspector owns storage, callbacks, remoting, EMA host flow, and mid-session UX. See [SDK consolidation](v2_auth_sdk_consolidation.md). **Policy:** SEP behavior that can be automated is covered (or should be covered) by unit/integration tests. Hosted-IdP smoke in [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) is complementary for real providers — it is **not** required for every SEP once CI covers the requirement. diff --git a/specification/v2_auth_sdk_consolidation.md b/specification/v2_auth_sdk_consolidation.md index f3711bd76..9c044911a 100644 --- a/specification/v2_auth_sdk_consolidation.md +++ b/specification/v2_auth_sdk_consolidation.md @@ -4,7 +4,7 @@ #### [Overview](v2_auth.md) | [EMA / XAA](v2_auth_ema.md) | [Hardening](v2_auth_hardening.md) | [Mid-session](v2_auth_mid_session.md) | [Smoke testing](v2_auth_smoke_testing.md) | SDK consolidation -Record of how Inspector uses `@modelcontextprotocol/client` **2.0.0-beta.4** for authorization after the v2 SDK upgrade: what we moved onto the SDK, what we left Inspector-owned and why, and which small SDK API gaps would let us delete more local wire later. +Record of how Inspector uses `@modelcontextprotocol/client` **2.0.0** for authorization after the v2 SDK upgrade: what we moved onto the SDK, what we left Inspector-owned and why, and which small SDK API gaps would let us delete more local wire later. Related as-built specs: [Hardening](v2_auth_hardening.md), [EMA](v2_auth_ema.md), [Mid-session](v2_auth_mid_session.md). diff --git a/test-servers/configs/duplicate-tool-names-http.json b/test-servers/configs/duplicate-tool-names-http.json new file mode 100644 index 000000000..af1699bec --- /dev/null +++ b/test-servers/configs/duplicate-tool-names-http.json @@ -0,0 +1,17 @@ +{ + "serverInfo": { + "name": "duplicate-tool-names", + "version": "1.0.0" + }, + "tools": [ + { "preset": "get_weather" }, + { "preset": "get_temp" }, + { "preset": "echo" }, + { "preset": "add" } + ], + "duplicateToolNames": ["get_weather", "echo"], + "transport": { + "type": "streamable-http", + "port": 3142 + } +} diff --git a/test-servers/configs/mcp-app-http.json b/test-servers/configs/mcp-app-http.json new file mode 100644 index 000000000..445966acc --- /dev/null +++ b/test-servers/configs/mcp-app-http.json @@ -0,0 +1,12 @@ +{ + "serverInfo": { + "name": "mcp-app-showcase", + "version": "1.0.0" + }, + "tools": [{ "preset": "echo" }, { "preset": "mcp_app_demo" }], + "resources": [{ "preset": "mcp_app_demo_widget" }], + "transport": { + "type": "streamable-http", + "port": 3130 + } +} diff --git a/test-servers/configs/structured-output-http.json b/test-servers/configs/structured-output-http.json new file mode 100644 index 000000000..48b9ae93b --- /dev/null +++ b/test-servers/configs/structured-output-http.json @@ -0,0 +1,15 @@ +{ + "serverInfo": { + "name": "structured-output-showcase", + "version": "1.0.0" + }, + "tools": [ + { "preset": "list_items" }, + { "preset": "get_temp" }, + { "preset": "echo" } + ], + "transport": { + "type": "streamable-http", + "port": 6601 + } +} diff --git a/test-servers/src/composable-test-server.ts b/test-servers/src/composable-test-server.ts index 4c836e4b9..648fad35f 100644 --- a/test-servers/src/composable-test-server.ts +++ b/test-servers/src/composable-test-server.ts @@ -472,6 +472,22 @@ export interface ServerConfig { resourceTemplates?: number; prompts?: number; }; + /** + * Emit the named registered tools **twice** in `tools/list`, with the same + * `name` on both entries and " (duplicate)" appended to the second's title. + * + * Nothing else in this repo can produce this shape: every preset registers a + * unique name, and the SDK's `registerTool` rejects a repeat. But a real + * server can and does return duplicates, and the Inspector has to render that + * faithfully rather than break on it (#1957 — duplicate names collided in the + * Tools sidebar's React keys, so filtering left unrelated rows on screen). + * + * Deliberately a list of names rather than a blanket flag, so a config can + * duplicate part of its tool set and leave the rest alone — that mix is what + * makes a search filter's behavior legible. A name that isn't registered is + * ignored. + */ + duplicateToolNames?: string[]; /** * Gate a tool's visibility in `tools/list` on a client-declared extension * (SEP-2133 `capabilities.extensions`). Maps extension id → tool name (the @@ -1107,16 +1123,45 @@ export function createMcpServer(config: ServerConfig): McpServer { // Set up pagination handlers if maxPageSize is configured const maxPageSize = config.maxPageSize || {}; - // Tools pagination - if (capabilities.tools && maxPageSize.tools !== undefined) { + // Emit each named tool a second time, same `name`, title marked so the two + // rows are told apart on screen. See ServerConfig.duplicateToolNames (#1957). + // + // The second copies go **after** the whole list rather than beside their + // twin, which is both how a real server produces duplicates (two tool sources + // concatenated) and what makes the defect observable: React's child + // reconciliation walks a matching prefix first, so head-adjacent duplicates + // happen to line up and survive. It is the *separated* pair that collides in + // the keyed map and orphans a row. + const duplicateToolNames = new Set(config.duplicateToolNames ?? []); + const withDuplicates = (tools: Tool[]): Tool[] => + duplicateToolNames.size === 0 + ? tools + : [ + ...tools, + ...tools + .filter((tool) => duplicateToolNames.has(tool.name)) + .map((tool) => ({ + ...tool, + title: `${tool.title ?? tool.name} (duplicate)`, + })), + ]; + + // Tools pagination, and/or the duplicate-name override — both need the same + // hand-built list, so the handler is installed when either is configured. + if ( + capabilities.tools && + (maxPageSize.tools !== undefined || duplicateToolNames.size > 0) + ) { mcpServer.server.setRequestHandler("tools/list", async (request) => { const cursor = request.params?.cursor; - const pageSize = maxPageSize.tools!; + // No pagination configured: one page holding everything, so the duplicate + // override can share this handler without inventing a page size. + const pageSize = maxPageSize.tools ?? Number.MAX_SAFE_INTEGER; // Convert registered tools to Tool format, mirroring the SDK's tools/list. // The input-schema JSON comes from the SDK's memoised converter; the // output-schema JSON is the value the SDK cached at registration. - const allTools: Tool[] = []; + const registeredTools: Tool[] = []; for (const [name, registered] of state.registeredTools.entries()) { if (registered.enabled) { const toolDefinition: Record<string, unknown> = { @@ -1134,9 +1179,12 @@ export function createMcpServer(config: ServerConfig): McpServer { toolDefinition.outputSchema = registered.outputSchemaJson; } - allTools.push(toolDefinition as Tool); + registeredTools.push(toolDefinition as Tool); } } + // Duplicate before paginating, so a duplicated pair can straddle a page + // boundary exactly as a real server's would. + const allTools = withDuplicates(registeredTools); const startIndex = cursor ? parseInt(cursor, 10) : 0; const endIndex = startIndex + pageSize; diff --git a/test-servers/src/load-config.ts b/test-servers/src/load-config.ts index 8e2252009..7ee77a84d 100644 --- a/test-servers/src/load-config.ts +++ b/test-servers/src/load-config.ts @@ -66,6 +66,12 @@ export interface ConfigFile { resourceTemplates?: number; prompts?: number; }; + /** + * Names of registered tools to emit **twice** in `tools/list` (same `name`, + * the second's title marked "(duplicate)") — the nonconforming-but-real shape + * no preset can produce. See {@link ServerConfig.duplicateToolNames} (#1957). + */ + duplicateToolNames?: string[]; /** * Gate a tool's `tools/list` visibility on a client-declared extension. Maps * extension id → tool name; the tool appears only when the connected client diff --git a/test-servers/src/preset-registry.ts b/test-servers/src/preset-registry.ts index 6c7e6679b..806769b36 100644 --- a/test-servers/src/preset-registry.ts +++ b/test-servers/src/preset-registry.ts @@ -34,6 +34,7 @@ import { createGetAnnotatedMessageTool, createGetTempTool, createGetTempExtraTool, + createListItemsTool, createAddResourceTool, createRemoveResourceTool, createAddToolTool, @@ -158,6 +159,8 @@ function resolveToolPreset( return createGetTempTool(); case "get_temp_extra": return createGetTempExtraTool(); + case "list_items": + return createListItemsTool(); case "add_resource": return createAddResourceTool(); case "remove_resource": diff --git a/test-servers/src/resolve-config.ts b/test-servers/src/resolve-config.ts index 491bfed6b..bd229c944 100644 --- a/test-servers/src/resolve-config.ts +++ b/test-servers/src/resolve-config.ts @@ -91,6 +91,7 @@ export function resolveConfig(config: ConfigFile): ServerConfig { tasks: config.tasks, tasksExtension: config.tasksExtension, maxPageSize: config.maxPageSize, + duplicateToolNames: config.duplicateToolNames, extensionGatedTools: config.extensionGatedTools, serverType: isHttp ? (transport.type as "sse" | "streamable-http") diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts index a87311efa..5a05b07ba 100644 --- a/test-servers/src/test-server-fixtures.ts +++ b/test-servers/src/test-server-fixtures.ts @@ -1048,6 +1048,48 @@ export function createGetTempTool(): ToolDefinition { }; } +/** Output schema for list_items: a nested list of items plus a total. */ +const ListItemsOutputSchema = z.object({ + items: z + .array( + z.object({ + id: z.number().describe("Item id"), + name: z.string().describe("Item name"), + tags: z.array(z.string()).describe("Item tags"), + }), + ) + .describe("The items"), + total: z.number().describe("Number of items"), +}); + +/** + * Create a "list_items" tool returning a short `content` summary alongside a + * DEEPLY NESTED `structuredContent` (objects inside arrays inside an object) — + * the shape from #1908, where the structured payload carries everything the + * text block only summarizes. Exercises the Structured Output section of the + * Tools screen result panel, which `get_temp`'s flat three-key object does not. + */ +export function createListItemsTool(): ToolDefinition { + return { + name: "list_items", + description: "Returns a list of items with nested structured output", + inputSchema: {}, + outputSchema: ListItemsOutputSchema, + handler: async () => { + const items = [ + { id: 1, name: "Item A", tags: ["foo", "bar"] }, + { id: 2, name: "Item B", tags: ["baz"] }, + ]; + return { + content: [ + { type: "text" as const, text: `Found ${items.length} items.` }, + ], + structuredContent: { items, total: items.length }, + }; + }, + }; +} + /** * Create a "get_temp_extra" tool that declares the same output schema as * get_temp but returns an EXTRA, undeclared property in structuredContent. diff --git a/vitest.shared.mts b/vitest.shared.mts index 060e099f2..1ebbc96ae 100644 --- a/vitest.shared.mts +++ b/vitest.shared.mts @@ -82,9 +82,20 @@ export function vitestSharedPaths(clientDir: string) { find: /^@napi-rs\/keyring$/, replacement: path.resolve(dirname, "node_modules/@napi-rs/keyring"), }, + // `express` and `yaml` resolve from the **repo root**, unlike every pin + // above. Both are reached only through `test-servers/src` — express by the + // http/oauth servers, yaml by `load-config.ts` — which is root-owned code + // with no manifest of its own, so the root is where they are declared and + // the only place a client's resolution chain is guaranteed to find them. + // Pointing these at `<client>/node_modules` is what broke when the MCP + // packages moved to the root (#1970): express was never declared by a client + // at all, it arrived in `clients/cli` as a peer of `express-rate-limit` + // under `@modelcontextprotocol/server-legacy`, so removing that manifest + // entry took express with it and every cli test that spawns a test server + // failed to resolve it. { find: /^express$/, - replacement: path.resolve(dirname, "node_modules/express"), + replacement: path.resolve(repoRoot, "node_modules/express"), }, { find: /^yaml$/,